@kenz1117/dsh-ui-usage-billing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,698 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ //#region lib/types/client/pricing.js
5
+ /**
6
+ * Billing engine: per-model price tables and token-usage cost estimation.
7
+ *
8
+ * Each model's price table uses its NATIVE currency: domestic providers
9
+ * (DeepSeek, 智谱, 通义…) publish RMB prices and store them directly;
10
+ * overseas providers (OpenAI, Google, xAI, Meta) publish USD.
11
+ * Cost is always computed and displayed in CNY — only USD-priced models go
12
+ * through the exchange rate, never domestic ones.
13
+ *
14
+ * Google-style two-band billing is modeled per model: Gemini's Flex tier
15
+ * prices spare-capacity traffic at -50%; DeepSeek V4 splits peak
16
+ * (09:00-12:00 / 14:00-18:00 Beijing) at 2x the off-peak rate. The estimator
17
+ * mixes both bands by a configured peak share ({@link DEFAULT_PEAK_SHARE}).
18
+ */
19
+ /**
20
+ * USD → CNY rate for display. Source: China Foreign Exchange Trade System
21
+ * mid-rate 6.7878 on 2026-08-14; rounded to 6.79. Only applies to overseas
22
+ * USD-priced models — domestic models never pass through this rate.
23
+ */
24
+ const USD_TO_CNY = 6.79;
25
+ /** Default share of traffic assumed to fall in the peak band (0..1). */
26
+ const DEFAULT_PEAK_SHARE = .5;
27
+ /**
28
+ * Model keys served through a subscription plan (e.g. a coding plan or topic
29
+ * plan) instead of metered per-token API billing. Usage through these routes
30
+ * costs no tokens: the estimator treats them as ¥0 and the billing table
31
+ * labels them 订阅包含. Add any model key your deployment serves through a
32
+ * plan here; leave empty when every route is pay-as-you-go.
33
+ */
34
+ const SUBSCRIPTION_PLAN_KEYS = [];
35
+ /** Whether one stats model key is billed through a subscription plan. */
36
+ function isSubscriptionPlan(key) {
37
+ return SUBSCRIPTION_PLAN_KEYS.includes(key);
38
+ }
39
+ /**
40
+ * Built-in catalog of current mainstream models as of 2026-08-16, priced from
41
+ * each provider's official price page. Domestic providers are OpenAI-API
42
+ * compatible and publish RMB prices directly; overseas providers publish USD
43
+ * and convert through the exchange rate at estimate time. Retired models
44
+ * (GPT-4o family, Gemini 2.x, GLM-4.x-lite, older Qwen) are deliberately
45
+ * absent, as are Anthropic Claude models (their native API is not
46
+ * OpenAI-compatible, so the harness cannot drive them directly). DeepSeek
47
+ * keys match the harness stats file so real usage prices from the catalog;
48
+ * unknown keys fall back to `other`.
49
+ *
50
+ * Time-of-day billing (peak/off-peak) is now real: DeepSeek V4 officially
51
+ * splits peak (09:00-12:00 / 14:00-18:00 Beijing) at 2x the off-peak rate
52
+ * from 2026-08-17, and Gemini's Flex tier discounts spare-capacity traffic.
53
+ */
54
+ const MODEL_CATALOG = [
55
+ {
56
+ key: "flash",
57
+ name: "DeepSeek V4 Flash",
58
+ provider: "DeepSeek",
59
+ colorVar: "dsw-static-blue-500",
60
+ price: {
61
+ currency: "CNY",
62
+ input: 3,
63
+ cacheHit: .1,
64
+ output: 9,
65
+ offPeak: {
66
+ input: 1.5,
67
+ cacheHit: .05,
68
+ output: 4.5
69
+ }
70
+ },
71
+ peakHours: "09:00-12:00 / 14:00-18:00"
72
+ },
73
+ {
74
+ key: "pro",
75
+ name: "DeepSeek V4 Pro",
76
+ provider: "DeepSeek",
77
+ colorVar: "dsw-static-deepseek-500",
78
+ price: {
79
+ currency: "CNY",
80
+ input: 9,
81
+ cacheHit: .3,
82
+ output: 27,
83
+ offPeak: {
84
+ input: 4.5,
85
+ cacheHit: .15,
86
+ output: 13.5
87
+ }
88
+ },
89
+ peakHours: "09:00-12:00 / 14:00-18:00"
90
+ },
91
+ {
92
+ key: "glm",
93
+ name: "GLM-5.2",
94
+ provider: "智谱 AI",
95
+ colorVar: "dsw-static-blue-600",
96
+ price: {
97
+ currency: "CNY",
98
+ input: 8,
99
+ cacheHit: 2,
100
+ output: 28
101
+ }
102
+ },
103
+ {
104
+ key: "glm-5.3",
105
+ name: "GLM-5.3",
106
+ provider: "智谱 AI",
107
+ colorVar: "dsw-static-blue-500",
108
+ price: {
109
+ currency: "CNY",
110
+ input: 8,
111
+ cacheHit: 2,
112
+ output: 28
113
+ }
114
+ },
115
+ {
116
+ key: "glm-4.6",
117
+ name: "GLM-4.6",
118
+ provider: "智谱 AI",
119
+ colorVar: "dsw-static-blue-400",
120
+ price: {
121
+ currency: "CNY",
122
+ input: 4,
123
+ cacheHit: .8,
124
+ output: 16
125
+ }
126
+ },
127
+ {
128
+ key: "qwen-3.8-max",
129
+ name: "Qwen3.8 Max",
130
+ provider: "阿里通义",
131
+ colorVar: "dsw-static-blue-600",
132
+ price: {
133
+ currency: "CNY",
134
+ input: 13.58,
135
+ cacheHit: 1.36,
136
+ output: 40.74
137
+ }
138
+ },
139
+ {
140
+ key: "qwen-max",
141
+ name: "Qwen3.7-Max",
142
+ provider: "阿里通义",
143
+ colorVar: "dsw-static-blue-300",
144
+ price: {
145
+ currency: "CNY",
146
+ input: 6,
147
+ cacheHit: .6,
148
+ output: 18
149
+ }
150
+ },
151
+ {
152
+ key: "qwen-plus",
153
+ name: "Qwen3.5-Plus",
154
+ provider: "阿里通义",
155
+ colorVar: "dsw-static-blue-500",
156
+ price: {
157
+ currency: "CNY",
158
+ input: .8,
159
+ cacheHit: .08,
160
+ output: 4.8
161
+ }
162
+ },
163
+ {
164
+ key: "qwen-flash",
165
+ name: "Qwen3.5-Flash",
166
+ provider: "阿里通义",
167
+ colorVar: "dsw-static-blue-400",
168
+ price: {
169
+ currency: "CNY",
170
+ input: .2,
171
+ cacheHit: .02,
172
+ output: 2
173
+ }
174
+ },
175
+ {
176
+ key: "doubao",
177
+ name: "Doubao Seed-2.0 Pro",
178
+ provider: "字节豆包",
179
+ colorVar: "dsw-static-red-500",
180
+ price: {
181
+ currency: "CNY",
182
+ input: 3.2,
183
+ cacheHit: .64,
184
+ output: 16
185
+ }
186
+ },
187
+ {
188
+ key: "doubao-mini",
189
+ name: "Doubao Seed-2.0 Mini",
190
+ provider: "字节豆包",
191
+ colorVar: "dsw-static-red-300",
192
+ price: {
193
+ currency: "CNY",
194
+ input: .2,
195
+ cacheHit: .02,
196
+ output: 2
197
+ }
198
+ },
199
+ {
200
+ key: "doubao-1.6",
201
+ name: "Doubao Seed-1.6",
202
+ provider: "字节豆包",
203
+ colorVar: "dsw-static-red-400",
204
+ price: {
205
+ currency: "CNY",
206
+ input: .8,
207
+ cacheHit: 0,
208
+ output: 8
209
+ }
210
+ },
211
+ {
212
+ key: "kimi",
213
+ name: "Kimi K2.7 Code",
214
+ provider: "月之暗面",
215
+ colorVar: "dsw-static-neutral-bluish-700",
216
+ price: {
217
+ currency: "CNY",
218
+ input: 6.5,
219
+ cacheHit: 1.3,
220
+ output: 27
221
+ }
222
+ },
223
+ {
224
+ key: "kimi-k2.7-hs",
225
+ name: "Kimi K2.7 Code HighSpeed",
226
+ provider: "月之暗面",
227
+ colorVar: "dsw-static-neutral-bluish-600",
228
+ price: {
229
+ currency: "CNY",
230
+ input: 13,
231
+ cacheHit: 2.6,
232
+ output: 54
233
+ }
234
+ },
235
+ {
236
+ key: "kimi-k2.6",
237
+ name: "Kimi K2.6",
238
+ provider: "月之暗面",
239
+ colorVar: "dsw-static-neutral-bluish-500",
240
+ price: {
241
+ currency: "CNY",
242
+ input: 6.5,
243
+ cacheHit: 1.1,
244
+ output: 27
245
+ }
246
+ },
247
+ {
248
+ key: "kimi-k3",
249
+ name: "Kimi K3",
250
+ provider: "月之暗面",
251
+ colorVar: "dsw-static-neutral-bluish-500",
252
+ price: {
253
+ currency: "CNY",
254
+ input: 20,
255
+ cacheHit: 2,
256
+ output: 100
257
+ }
258
+ },
259
+ {
260
+ key: "minimax",
261
+ name: "MiniMax-M3",
262
+ provider: "MiniMax",
263
+ colorVar: "dsw-static-amber-500",
264
+ price: {
265
+ currency: "CNY",
266
+ input: 2.1,
267
+ cacheHit: .42,
268
+ output: 8.4
269
+ }
270
+ },
271
+ {
272
+ key: "ernie",
273
+ name: "ERNIE-5.1",
274
+ provider: "百度文心",
275
+ colorVar: "dsw-static-blue-300",
276
+ price: {
277
+ currency: "CNY",
278
+ input: 4,
279
+ cacheHit: .4,
280
+ output: 18
281
+ }
282
+ },
283
+ {
284
+ key: "hunyuan",
285
+ name: "混元 Hy3",
286
+ provider: "腾讯混元",
287
+ colorVar: "dsw-static-amber-400",
288
+ price: {
289
+ currency: "CNY",
290
+ input: 1,
291
+ cacheHit: .25,
292
+ output: 4
293
+ }
294
+ },
295
+ {
296
+ key: "hunyuan-t1",
297
+ name: "混元 T1",
298
+ provider: "腾讯混元",
299
+ colorVar: "dsw-static-amber-300",
300
+ price: {
301
+ currency: "CNY",
302
+ input: 1,
303
+ cacheHit: .1,
304
+ output: 4
305
+ }
306
+ },
307
+ {
308
+ key: "yi",
309
+ name: "Yi-Lightning",
310
+ provider: "零一万物",
311
+ colorVar: "dsw-static-green-500",
312
+ price: {
313
+ currency: "CNY",
314
+ input: .99,
315
+ cacheHit: .1,
316
+ output: .99
317
+ }
318
+ },
319
+ {
320
+ key: "step",
321
+ name: "Step 3.7 Flash",
322
+ provider: "阶跃星辰",
323
+ colorVar: "dsw-static-neutral-bluish-400",
324
+ price: {
325
+ currency: "CNY",
326
+ input: 1.35,
327
+ cacheHit: .27,
328
+ output: 8.1
329
+ }
330
+ },
331
+ {
332
+ key: "spark",
333
+ name: "Spark 4.0 Ultra",
334
+ provider: "科大讯飞",
335
+ colorVar: "dsw-static-green-400",
336
+ price: {
337
+ currency: "CNY",
338
+ input: 5,
339
+ cacheHit: .5,
340
+ output: 10
341
+ }
342
+ },
343
+ {
344
+ key: "sensenova",
345
+ name: "SenseNova 6.5",
346
+ provider: "商汤",
347
+ colorVar: "dsw-static-red-400",
348
+ price: {
349
+ currency: "CNY",
350
+ input: 4.5,
351
+ cacheHit: .45,
352
+ output: 9
353
+ }
354
+ },
355
+ {
356
+ key: "baichuan",
357
+ name: "Baichuan M3-Plus",
358
+ provider: "百川智能",
359
+ colorVar: "dsw-static-neutral-bluish-500",
360
+ price: {
361
+ currency: "CNY",
362
+ input: 5,
363
+ cacheHit: .5,
364
+ output: 9
365
+ }
366
+ },
367
+ {
368
+ key: "gpt-5.6-sol",
369
+ name: "GPT-5.6 Sol",
370
+ provider: "OpenAI",
371
+ colorVar: "dsw-static-green-500",
372
+ price: {
373
+ currency: "USD",
374
+ input: 5,
375
+ cacheHit: .5,
376
+ output: 30
377
+ }
378
+ },
379
+ {
380
+ key: "gpt-5.6-terra",
381
+ name: "GPT-5.6 Terra",
382
+ provider: "OpenAI",
383
+ colorVar: "dsw-static-green-400",
384
+ price: {
385
+ currency: "USD",
386
+ input: 2,
387
+ cacheHit: .2,
388
+ output: 12
389
+ }
390
+ },
391
+ {
392
+ key: "gpt-5.6-luna",
393
+ name: "GPT-5.6 Luna",
394
+ provider: "OpenAI",
395
+ colorVar: "dsw-static-green-500",
396
+ price: {
397
+ currency: "USD",
398
+ input: .2,
399
+ cacheHit: .02,
400
+ output: 1.2
401
+ }
402
+ },
403
+ {
404
+ key: "gemini-pro",
405
+ name: "Gemini 3.1 Pro",
406
+ provider: "Google",
407
+ colorVar: "dsw-static-blue-600",
408
+ price: {
409
+ currency: "USD",
410
+ input: 2,
411
+ cacheHit: .2,
412
+ output: 12,
413
+ offPeak: {
414
+ input: 1,
415
+ cacheHit: .1,
416
+ output: 6
417
+ }
418
+ },
419
+ peakHours: "Standard / Flex"
420
+ },
421
+ {
422
+ key: "gemini-flash",
423
+ name: "Gemini 3.6 Flash",
424
+ provider: "Google",
425
+ colorVar: "dsw-static-blue-400",
426
+ price: {
427
+ currency: "USD",
428
+ input: 1.5,
429
+ cacheHit: .15,
430
+ output: 7.5,
431
+ offPeak: {
432
+ input: .75,
433
+ cacheHit: .075,
434
+ output: 3.75
435
+ }
436
+ },
437
+ peakHours: "Standard / Flex"
438
+ },
439
+ {
440
+ key: "grok",
441
+ name: "Grok 4.6",
442
+ provider: "xAI",
443
+ colorVar: "dsw-static-neutral-bluish-700",
444
+ price: {
445
+ currency: "USD",
446
+ input: 2,
447
+ cacheHit: .5,
448
+ output: 6
449
+ }
450
+ },
451
+ {
452
+ key: "grok-4.3",
453
+ name: "Grok 4.3",
454
+ provider: "xAI",
455
+ colorVar: "dsw-static-neutral-bluish-500",
456
+ price: {
457
+ currency: "USD",
458
+ input: 1.25,
459
+ cacheHit: .2,
460
+ output: 2.5
461
+ }
462
+ },
463
+ {
464
+ key: "llama",
465
+ name: "Llama 4 Maverick",
466
+ provider: "Meta",
467
+ colorVar: "dsw-static-red-500",
468
+ price: {
469
+ currency: "USD",
470
+ input: .2,
471
+ cacheHit: .05,
472
+ output: .6
473
+ }
474
+ },
475
+ {
476
+ key: "llama-scout",
477
+ name: "Llama 4 Scout",
478
+ provider: "Meta",
479
+ colorVar: "dsw-static-red-400",
480
+ price: {
481
+ currency: "USD",
482
+ input: .1,
483
+ cacheHit: .025,
484
+ output: .3
485
+ }
486
+ },
487
+ {
488
+ key: "other",
489
+ name: "其他模型",
490
+ provider: "Custom",
491
+ colorVar: "dsw-static-neutral-bluish-500",
492
+ price: {
493
+ currency: "CNY",
494
+ input: .5,
495
+ cacheHit: .25,
496
+ cacheMiss: .5,
497
+ output: 1.5
498
+ }
499
+ }
500
+ ];
501
+ /** Lookup a model by its stats key; falls back to the generic `other` entry. */
502
+ function modelOf(key) {
503
+ return MODEL_CATALOG.find((entry) => entry.key === key) ?? MODEL_CATALOG[MODEL_CATALOG.length - 1];
504
+ }
505
+ /**
506
+ * Price one band's token usage in CNY. The stats `input` field is the TOTAL
507
+ * prompt tokens (cacheHit + cacheMiss), so billing splits it: the cache-hit
508
+ * share prices at the hit rate and the remaining share at the miss rate.
509
+ * Providers that report only disjoint buckets carry `cacheMiss` explicitly;
510
+ * otherwise the miss share is derived as `input - cacheHit`. Only USD-priced
511
+ * bands go through the exchange rate.
512
+ */
513
+ function priceBandCost(band, buckets, currency) {
514
+ const miss = buckets.cacheMiss > 0 ? buckets.cacheMiss : Math.max(0, buckets.input - buckets.cacheHit);
515
+ const hit = Math.min(buckets.cacheHit, buckets.input);
516
+ const raw = (miss * (band.cacheMiss ?? band.input) + hit * band.cacheHit + buckets.output * band.output) / 1e6;
517
+ return currency === "USD" ? raw * USD_TO_CNY : raw;
518
+ }
519
+ /**
520
+ * Estimate the CNY cost of one model's token usage, mixing the peak and
521
+ * off-peak bands by the given peak share (flat-priced models cost the same in
522
+ * both bands).
523
+ * @param entry - the catalog entry whose prices apply.
524
+ * @param buckets - token usage counts.
525
+ * @param peakShare - share of traffic in the peak band (0..1); defaults to {@link DEFAULT_PEAK_SHARE}.
526
+ * @returns the estimated cost in CNY.
527
+ */
528
+ function computeCost(entry, buckets, peakShare = DEFAULT_PEAK_SHARE) {
529
+ if (isSubscriptionPlan(entry.key)) return 0;
530
+ const peak = priceBandCost(entry.price, buckets, entry.price.currency);
531
+ const off = entry.price.offPeak === void 0 ? peak : priceBandCost(entry.price.offPeak, buckets, entry.price.currency);
532
+ return peak * peakShare + off * (1 - peakShare);
533
+ }
534
+ //#endregion
535
+ //#region lib/types/aggregate.js
536
+ /**
537
+ * Real-usage aggregation: folds every persisted session log into the
538
+ * usage-stats document the dashboard renders.
539
+ *
540
+ * Each LLM call is attributed to the model of the `request/header` event that
541
+ * precedes its `assistant/message` usage event. Costs are estimated with the
542
+ * shared billing catalog (`pricing.ts`, in CNY), so only models the catalog
543
+ * prices incur a cost — subscription-plan routes and unknown models price
544
+ * zero while their tokens still count. Pure functions only: the persistence
545
+ * handle is injected, so the fold is unit-testable without a host.
546
+ */
547
+ /**
548
+ * Real provider model ids map to their billing-catalog keys. Unknown ids stay
549
+ * as-is and price zero (they are not in the catalog; subscription-plan routes
550
+ * like kimi-coding / token plans fall here and therefore cost nothing).
551
+ */
552
+ const MODEL_KEY_ALIASES = {
553
+ "deepseek-v4-flash": "flash",
554
+ "deepseek-v4-pro": "pro",
555
+ "glm-5.2": "glm",
556
+ "qwen3.8-max": "qwen-3.8-max",
557
+ "qwen3.7-max": "qwen-max",
558
+ "qwen-max": "qwen-max",
559
+ "hunyuan-t1": "hunyuan-t1",
560
+ "step-3.7-flash": "step",
561
+ "seed-2.0-mini": "doubao-mini"
562
+ };
563
+ /**
564
+ * 走订阅套餐(coding / token / agent plan)的 provider id:这些通道的调用
565
+ * 按套餐计费,不再按 token 计费,因此即使模型 id 与计费表撞名也一律豁免。
566
+ * 部署可在 plugin config 的 `subscriptionProviders` 中覆盖。
567
+ */
568
+ const DEFAULT_SUBSCRIPTION_PROVIDERS = ["kimi-coding", "xiaomi-token-plan-cn"];
569
+ /** Zeroed usage accumulator. */
570
+ function emptyUsage() {
571
+ return {
572
+ calls: 0,
573
+ input: 0,
574
+ output: 0,
575
+ cacheHit: 0,
576
+ cacheMiss: 0,
577
+ cost: 0
578
+ };
579
+ }
580
+ /**
581
+ * Fold one token usage event into an accumulator and re-price its cost.
582
+ * The stats `input` is the TOTAL prompt tokens (cacheHit + cacheMiss), so the
583
+ * miss bucket is uncached input plus cache writes.
584
+ * @param acc - the accumulator to mutate.
585
+ * @param usage - the provider-reported usage of one call.
586
+ * @param key - the billing-catalog key this call belongs to.
587
+ * @param subscription - whether the call went through a subscription plan; such calls never cost money.
588
+ */
589
+ function foldUsage(acc, usage, key, subscription) {
590
+ const cacheHit = usage.cacheReadTokens ?? 0;
591
+ const cacheMiss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
592
+ acc.calls += 1;
593
+ acc.input += usage.inputTokens + cacheHit + (usage.cacheWriteTokens ?? 0);
594
+ acc.output += usage.outputTokens;
595
+ acc.cacheHit += cacheHit;
596
+ acc.cacheMiss += cacheMiss;
597
+ acc.cost = !subscription && MODEL_CATALOG.some((entry) => entry.key === key) ? computeCost(modelOf(key), {
598
+ input: acc.input,
599
+ cacheHit: acc.cacheHit,
600
+ cacheMiss: acc.cacheMiss,
601
+ output: acc.output
602
+ }) : 0;
603
+ }
604
+ /** Local-time date stamp (the host runs in the user's timezone). */
605
+ function dayStamp(time) {
606
+ const date = new Date(time);
607
+ const pad = (n) => String(n).padStart(2, "0");
608
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
609
+ }
610
+ /**
611
+ * Aggregate real usage from every persisted session log.
612
+ * @param persistence - the session persistence service.
613
+ * @param options - aggregation tuning (e.g. subscription-plan providers).
614
+ * @returns the usage-stats document (same shape the dashboard expects).
615
+ */
616
+ async function aggregateUsage(persistence, options = {}) {
617
+ const subscriptionProviders = new Set(options.subscriptionProviders ?? DEFAULT_SUBSCRIPTION_PROVIDERS);
618
+ const total = emptyUsage();
619
+ const byModel = /* @__PURE__ */ new Map();
620
+ const byDay = /* @__PURE__ */ new Map();
621
+ for (const meta of await persistence.list()) {
622
+ const { events } = await persistence.readFrom(meta.id, 0);
623
+ let key = "other";
624
+ let subscription = false;
625
+ for (const event of events) {
626
+ if (event.type === "request/header") {
627
+ const { model, provider } = event.data.header.config;
628
+ key = MODEL_KEY_ALIASES[model] ?? model;
629
+ subscription = subscriptionProviders.has(provider);
630
+ continue;
631
+ }
632
+ if (event.type !== "assistant/message" || event.data.usage === void 0) continue;
633
+ const modelKey = key;
634
+ const day = dayStamp(event.time);
635
+ foldUsage(total, event.data.usage, modelKey, subscription);
636
+ foldUsage(byModel.get(modelKey) ?? byModel.set(modelKey, emptyUsage()).get(modelKey), event.data.usage, modelKey, subscription);
637
+ foldUsage(byDay.get(day) ?? byDay.set(day, emptyUsage()).get(day), event.data.usage, modelKey, subscription);
638
+ }
639
+ }
640
+ const toRecord = (map) => Object.fromEntries(map);
641
+ return {
642
+ version: 1,
643
+ updatedAt: Date.now(),
644
+ source: "session-logs",
645
+ total,
646
+ byModel: toRecord(byModel),
647
+ byDay: toRecord(byDay)
648
+ };
649
+ }
650
+ //#endregion
651
+ //#region lib/types/index.js
652
+ /**
653
+ * Usage billing surface plugin, node half.
654
+ *
655
+ * Serves `/api/billing/usage-stats`: real usage aggregated from every
656
+ * persisted session log (see `aggregate.ts`) — the browser dashboard reads it
657
+ * instead of showing an empty snapshot. When `sessionPersistence` is
658
+ * unavailable (or aggregation fails), the configured `statsPath` /
659
+ * `DSH_USAGE_STATS` / conventional JSON file is served as a fallback, and a
660
+ * missing file answers `{ error }` so the dashboard shows zeros, never
661
+ * fabricated samples.
662
+ */
663
+ /** Required services: the web server and the persisted session log store. */
664
+ const inject = ["webServer", "sessionPersistence"];
665
+ /**
666
+ * Host plugin body: serve real aggregated usage to the browser dashboard.
667
+ * @param ctx - host context carrying webServer and sessionPersistence.
668
+ * @param config - optional statsPath override.
669
+ */
670
+ function apply(ctx, config = {}) {
671
+ const cwd = process.cwd();
672
+ const candidates = [
673
+ config.statsPath,
674
+ process.env.DSH_USAGE_STATS,
675
+ join(cwd, ".dsh-usage-stats.json"),
676
+ join(homedir(), ".dsh/.dsh-usage-stats.json")
677
+ ].filter((path) => typeof path === "string" && path.length > 0);
678
+ ctx.effect(() => ctx.webServer.register({
679
+ kind: "exact",
680
+ path: "/api/billing/usage-stats",
681
+ handler: async (_req, res) => {
682
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
683
+ try {
684
+ res.end(JSON.stringify(await aggregateUsage(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } })));
685
+ return;
686
+ } catch {}
687
+ for (const candidate of candidates) try {
688
+ const text = await readFile(candidate, "utf8");
689
+ JSON.parse(text);
690
+ res.end(text);
691
+ return;
692
+ } catch {}
693
+ res.end(JSON.stringify({ error: "usage stats unavailable" }));
694
+ }
695
+ }), "usage-billing: usage-stats route");
696
+ }
697
+ //#endregion
698
+ export { apply, inject };
@@ -0,0 +1,15 @@
1
+ //#region lib/types/invariant.js
2
+ /** Package invariant companion for `@kenz1117/dsh-ui-usage-billing`. */
3
+ const PACKAGE_NAME = "@kenz1117/dsh-ui-usage-billing";
4
+ const name = "usage-billing-invariant";
5
+ const inject = ["invariants"];
6
+ /** No runtime invariant: pure UI surface plugin with no lifecycle dependencies. */
7
+ const install = () => {};
8
+ /**
9
+ * Register this package's invariant companion.
10
+ * @param ctx - Host context carrying the invariant registry.
11
+ * @returns the registration disposer after setup succeeds.
12
+ */
13
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
14
+ //#endregion
15
+ export { apply, inject, name };