@kenz1117/dsh-ui-usage-billing 0.2.6 → 0.4.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 CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
@@ -9,16 +9,20 @@ function currentRate() {
9
9
  /** Default share of traffic assumed to fall in the peak band (0..1). */
10
10
  const DEFAULT_PEAK_SHARE = .5;
11
11
  /**
12
- * Model keys served through a subscription plan (e.g. a coding plan or topic
13
- * plan) instead of metered per-token API billing. Usage through these routes
14
- * costs no tokens: the estimator treats them as ¥0 and the billing table
15
- * labels them 订阅包含. Add any model key your deployment serves through a
16
- * plan here; leave empty when every route is pay-as-you-go.
12
+ * 高峰时段判定(北京时间,UTC+8,无夏令时):09:00–12:00、14:00–18:00。
13
+ * @param beijingHour - 北京时间的小时数(0–23)。
17
14
  */
18
- const SUBSCRIPTION_PLAN_KEYS = [];
19
- /** Whether one stats model key is billed through a subscription plan. */
20
- function isSubscriptionPlan(key) {
21
- return SUBSCRIPTION_PLAN_KEYS.includes(key);
15
+ function isPeakHour(beijingHour) {
16
+ return beijingHour >= 9 && beijingHour < 12 || beijingHour >= 14 && beijingHour < 18;
17
+ }
18
+ /**
19
+ * 由时刻(epoch 毫秒)推断计费时段;时刻未知/非法时按高峰计(保守:未知
20
+ * 时刻不低估成本,与社区 dsh-usage-chart 的 tierAt 语义一致)。
21
+ * @param timeMs - Unix epoch 毫秒;null/undefined/NaN 视为未知。
22
+ */
23
+ function tierAt(timeMs) {
24
+ if (timeMs === null || timeMs === void 0 || !Number.isFinite(timeMs)) return "peak";
25
+ return isPeakHour((new Date(timeMs).getUTCHours() + 8) % 24) ? "peak" : "offPeak";
22
26
  }
23
27
  /**
24
28
  * Built-in catalog of current mainstream models as of 2026-08-16, priced from
@@ -54,6 +58,24 @@ const MODEL_CATALOG = [
54
58
  },
55
59
  peakHours: "09:00-12:00 / 14:00-18:00"
56
60
  },
61
+ {
62
+ key: "flash-vision-exp",
63
+ name: "DeepSeek V4 Flash Vision (Exp)",
64
+ provider: "DeepSeek",
65
+ colorVar: "dsw-static-blue-500",
66
+ price: {
67
+ currency: "CNY",
68
+ input: 3,
69
+ cacheHit: .1,
70
+ output: 9,
71
+ offPeak: {
72
+ input: 1.5,
73
+ cacheHit: .05,
74
+ output: 4.5
75
+ }
76
+ },
77
+ peakHours: "09:00-12:00 / 14:00-18:00"
78
+ },
57
79
  {
58
80
  key: "pro",
59
81
  name: "DeepSeek V4 Pro",
@@ -240,6 +262,18 @@ const MODEL_CATALOG = [
240
262
  output: 100
241
263
  }
242
264
  },
265
+ {
266
+ key: "mimo-v2.5",
267
+ name: "MiMo V2.5",
268
+ provider: "小米",
269
+ colorVar: "dsw-static-green-400",
270
+ price: {
271
+ currency: "CNY",
272
+ input: 4,
273
+ cacheHit: .4,
274
+ output: 12
275
+ }
276
+ },
243
277
  {
244
278
  key: "minimax",
245
279
  name: "MiniMax-M3",
@@ -482,9 +516,30 @@ const MODEL_CATALOG = [
482
516
  }
483
517
  }
484
518
  ];
519
+ /**
520
+ * 真实 provider model id → 计费目录键(`MODEL_CATALOG[].key`)的映射。未知 id
521
+ * 原样保留并落回 `other`(未知模型不估算费用)。聚合层(aggregate.ts)在折叠时
522
+ * 用同一张表把日志里的 model id 归并为目录键,客户端渲染(`modelOf`)也按它
523
+ * 解析,两侧共用一份映射,避免同一模型两侧不一致导致「未收录」。
524
+ */
525
+ const MODEL_KEY_ALIASES = {
526
+ "deepseek-v4-flash": "flash",
527
+ "deepseek-v4-flash-vision-exp": "flash-vision-exp",
528
+ "deepseek-v4-pro": "pro",
529
+ "glm-5.2": "glm",
530
+ "qwen3.8-max": "qwen-3.8-max",
531
+ "qwen3.7-max": "qwen-max",
532
+ "qwen-max": "qwen-max",
533
+ "hunyuan-t1": "hunyuan-t1",
534
+ "step-3.7-flash": "step",
535
+ "seed-2.0-mini": "doubao-mini",
536
+ "k3": "kimi-k3",
537
+ "kimi-k3": "kimi-k3"
538
+ };
485
539
  /** Lookup a model by its stats key; falls back to the generic `other` entry. */
486
540
  function modelOf(key) {
487
- return MODEL_CATALOG.find((entry) => entry.key === key) ?? (() => {
541
+ const resolved = MODEL_KEY_ALIASES[key] ?? key;
542
+ return MODEL_CATALOG.find((entry) => entry.key === resolved) ?? (() => {
488
543
  const fallback = MODEL_CATALOG.at(-1);
489
544
  if (fallback !== void 0) return fallback;
490
545
  throw new Error("MODEL_CATALOG must not be empty");
@@ -520,11 +575,25 @@ function priceBandCost(band, buckets, currency) {
520
575
  * @returns the estimated cost in CNY.
521
576
  */
522
577
  function computeCost(entry, buckets, peakShare = DEFAULT_PEAK_SHARE) {
523
- if (isSubscriptionPlan(entry.key)) return 0;
524
578
  const peak = priceBandCost(entry.price, buckets, entry.price.currency);
525
579
  const off = entry.price.offPeak === void 0 ? peak : priceBandCost(entry.price.offPeak, buckets, entry.price.currency);
526
580
  return peak * peakShare + off * (1 - peakShare);
527
581
  }
582
+ /**
583
+ * 按调用时刻精确判定高峰/空闲档并计价(P0-1:替代固定比例混合)。时刻未知
584
+ * (null/NaN,理论不发生在真实事件流)时回退 {@link DEFAULT_PEAK_SHARE} 混合,
585
+ * 保持旧语义不低估。平档模型(无 offPeak)两个时段同价。
586
+ * @param entry - the catalog entry whose prices apply.
587
+ * @param buckets - token usage counts.
588
+ * @param timeMs - the call's wall-clock time (epoch ms); null falls back to the peak-share mix.
589
+ * @param peakShare - fallback mix used only when `timeMs` is missing.
590
+ * @returns the estimated cost in the entry's native currency.
591
+ */
592
+ function computeCostAt(entry, buckets, timeMs, peakShare = DEFAULT_PEAK_SHARE) {
593
+ if (entry.price.offPeak === void 0) return priceBandCost(entry.price, buckets, entry.price.currency);
594
+ if (timeMs === null || timeMs === void 0 || !Number.isFinite(timeMs)) return computeCost(entry, buckets, peakShare);
595
+ return priceBandCost(tierAt(timeMs) === "peak" ? entry.price : entry.price.offPeak, buckets, entry.price.currency);
596
+ }
528
597
  //#endregion
529
598
  //#region lib/types/aggregate.js
530
599
  /**
@@ -539,27 +608,23 @@ function computeCost(entry, buckets, peakShare = DEFAULT_PEAK_SHARE) {
539
608
  * handle is injected, so the fold is unit-testable without a host.
540
609
  */
541
610
  /**
542
- * Real provider model ids map to their billing-catalog keys. Unknown ids stay
543
- * as-is and price zero (they are not in the catalog; subscription-plan routes
544
- * like kimi-coding / token plans fall here and therefore cost nothing).
545
- */
546
- const MODEL_KEY_ALIASES = {
547
- "deepseek-v4-flash": "flash",
548
- "deepseek-v4-pro": "pro",
549
- "glm-5.2": "glm",
550
- "qwen3.8-max": "qwen-3.8-max",
551
- "qwen3.7-max": "qwen-max",
552
- "qwen-max": "qwen-max",
553
- "hunyuan-t1": "hunyuan-t1",
554
- "step-3.7-flash": "step",
555
- "seed-2.0-mini": "doubao-mini"
556
- };
557
- /**
558
- * 走订阅套餐(coding / token / agent plan)的 provider id:这些通道的调用
559
- * 按套餐计费,不再按 token 计费,因此即使模型 id 与计费表撞名也一律豁免。
560
- * 部署可在 plugin config 的 `subscriptionProviders` 中覆盖。
611
+ * 走订阅套餐(coding / token plan / opencode 订阅)的 provider id:这些通道的
612
+ * 调用按套餐计费,不再按 token 计费,因此即使模型 id 与计费表撞名也一律豁免。
613
+ * pi-ai 内置提供方对齐(含各地区变体:qwen/xiaomi token-plan、opencode
614
+ * opencode-go、zai-coding-cn);部署可在 plugin config 的 `subscriptionProviders`
615
+ * 中覆盖。
561
616
  */
562
- const DEFAULT_SUBSCRIPTION_PROVIDERS = ["kimi-coding", "xiaomi-token-plan-cn"];
617
+ const DEFAULT_SUBSCRIPTION_PROVIDERS = [
618
+ "kimi-coding",
619
+ "zai-coding-cn",
620
+ "opencode",
621
+ "opencode-go",
622
+ "qwen-token-plan",
623
+ "qwen-token-plan-cn",
624
+ "xiaomi-token-plan-ams",
625
+ "xiaomi-token-plan-cn",
626
+ "xiaomi-token-plan-sgp"
627
+ ];
563
628
  /** Zeroed usage accumulator. */
564
629
  function emptyUsage() {
565
630
  return {
@@ -579,8 +644,9 @@ function emptyUsage() {
579
644
  * @param usage - the provider-reported usage of one call.
580
645
  * @param key - the billing-catalog key this call belongs to.
581
646
  * @param subscription - whether the call went through a subscription plan; such calls never cost money.
647
+ * @param timeMs - the call's wall-clock time (epoch ms); drives peak/off-peak pricing.
582
648
  */
583
- function foldUsage(acc, usage, key, subscription) {
649
+ function foldUsage(acc, usage, key, subscription, timeMs) {
584
650
  const cacheHit = usage.cacheReadTokens ?? 0;
585
651
  const cacheMiss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
586
652
  acc.calls += 1;
@@ -588,12 +654,12 @@ function foldUsage(acc, usage, key, subscription) {
588
654
  acc.output += usage.outputTokens;
589
655
  acc.cacheHit += cacheHit;
590
656
  acc.cacheMiss += cacheMiss;
591
- acc.cost = !subscription && MODEL_CATALOG.some((entry) => entry.key === key) ? computeCost(modelOf(key), {
592
- input: acc.input,
593
- cacheHit: acc.cacheHit,
594
- cacheMiss: acc.cacheMiss,
595
- output: acc.output
596
- }) : 0;
657
+ if (!subscription && MODEL_CATALOG.some((entry) => entry.key === key)) acc.cost += computeCostAt(modelOf(key), {
658
+ input: cacheHit + cacheMiss,
659
+ cacheHit,
660
+ cacheMiss,
661
+ output: usage.outputTokens
662
+ }, timeMs);
597
663
  }
598
664
  /** Local-time date stamp (the host runs in the user's timezone). */
599
665
  function dayStamp(time) {
@@ -601,6 +667,11 @@ function dayStamp(time) {
601
667
  const pad = (n) => String(n).padStart(2, "0");
602
668
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
603
669
  }
670
+ /** 工作区名:取 cwd 的末级目录名;无 cwd 时返回 {@link UNKNOWN_WORKSPACE_NAME}。 */
671
+ function workspaceNameOf(cwd) {
672
+ if (cwd === void 0 || cwd === "") return "—";
673
+ return cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? "—";
674
+ }
604
675
  /** Get-or-create one model cell inside a usage map (avoids non-null assertions). */
605
676
  function usageCell(map, key) {
606
677
  const existing = map.get(key);
@@ -618,49 +689,237 @@ function modelDayCell(map, day, modelKey) {
618
689
  }
619
690
  return usageCell(models, modelKey);
620
691
  }
692
+ /** Get-or-create one turn's accumulation state. */
693
+ function turnState(turns, turn) {
694
+ const existing = turns.get(turn);
695
+ if (existing !== void 0) return existing;
696
+ const fresh = {
697
+ turn,
698
+ model: "other",
699
+ input: 0,
700
+ output: 0,
701
+ cacheHit: 0,
702
+ cacheMiss: 0,
703
+ cost: 0,
704
+ startedAt: Number.MAX_SAFE_INTEGER
705
+ };
706
+ turns.set(turn, fresh);
707
+ return fresh;
708
+ }
621
709
  /**
622
- * Aggregate real usage from every persisted session log.
710
+ * Fold one session's events into a {@link SessionFold}. 每个 LLM 调用归属到
711
+ * 其前置 request/header 记录的模型;同时提取最新会话标题、最后活跃时间,
712
+ * 并按轮次折叠每轮费用明细(turn/start → turn/end;调用按 (turn) 归组)。
713
+ * @param events - the session's persisted events in log order.
714
+ * @param subscriptionProviders - provider ids billed through subscription plans.
715
+ * @returns the per-session fold (cached by the incremental aggregator).
716
+ */
717
+ function foldSession(events, subscriptionProviders) {
718
+ const fold = {
719
+ total: emptyUsage(),
720
+ byModel: /* @__PURE__ */ new Map(),
721
+ byDay: /* @__PURE__ */ new Map(),
722
+ byDayModels: /* @__PURE__ */ new Map(),
723
+ planCalls: /* @__PURE__ */ new Map(),
724
+ turns: [],
725
+ lastActive: 0
726
+ };
727
+ let key = "other";
728
+ let subscription = false;
729
+ const turns = /* @__PURE__ */ new Map();
730
+ for (const event of events) {
731
+ fold.lastActive = Math.max(fold.lastActive, event.time);
732
+ if (event.type === "session/title") {
733
+ const title = event.data.title;
734
+ if (typeof title === "string" && title.length > 0) fold.title = title;
735
+ continue;
736
+ }
737
+ if (event.type === "turn/start") {
738
+ const state = turnState(turns, event.data.turn ?? -1);
739
+ if (event.time < state.startedAt) state.startedAt = event.time;
740
+ continue;
741
+ }
742
+ if (event.type === "turn/end") {
743
+ const turn = event.data.turn ?? -1;
744
+ const state = turns.get(turn);
745
+ if (state !== void 0) state.endedAt = event.time;
746
+ continue;
747
+ }
748
+ if (event.type === "request/header") {
749
+ const { model, provider } = event.data.header.config;
750
+ key = MODEL_KEY_ALIASES[model] ?? model;
751
+ subscription = subscriptionProviders.has(provider);
752
+ continue;
753
+ }
754
+ if (event.type !== "assistant/message") continue;
755
+ const usage = event.data.usage;
756
+ if (usage === void 0) continue;
757
+ const modelKey = key;
758
+ const day = dayStamp(event.time);
759
+ foldUsage(fold.total, usage, modelKey, subscription, event.time);
760
+ foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription, event.time);
761
+ foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time);
762
+ foldUsage(modelDayCell(fold.byDayModels, day, modelKey), usage, modelKey, subscription, event.time);
763
+ if (subscription) fold.planCalls.set(modelKey, (fold.planCalls.get(modelKey) ?? 0) + 1);
764
+ const state = turnState(turns, event.data.turn ?? -1);
765
+ state.model = modelKey;
766
+ state.input += usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
767
+ state.output += usage.outputTokens;
768
+ state.cacheHit += usage.cacheReadTokens ?? 0;
769
+ state.cacheMiss += usage.inputTokens + (usage.cacheWriteTokens ?? 0);
770
+ if (!subscription && MODEL_CATALOG.some((entry) => entry.key === modelKey)) state.cost += computeCostAt(modelOf(modelKey), {
771
+ input: (usage.cacheReadTokens ?? 0) + usage.inputTokens + (usage.cacheWriteTokens ?? 0),
772
+ cacheHit: usage.cacheReadTokens ?? 0,
773
+ cacheMiss: usage.inputTokens + (usage.cacheWriteTokens ?? 0),
774
+ output: usage.outputTokens
775
+ }, event.time);
776
+ if (state.startedAt === Number.MAX_SAFE_INTEGER) state.startedAt = event.time;
777
+ }
778
+ fold.turns = [...turns.values()].filter((state) => state.input > 0 || state.output > 0).sort((a, b) => a.turn - b.turn).map((state) => ({
779
+ turn: state.turn,
780
+ model: state.model,
781
+ input: state.input,
782
+ output: state.output,
783
+ cacheHit: state.cacheHit,
784
+ cacheMiss: state.cacheMiss,
785
+ cost: state.cost,
786
+ startedAt: state.startedAt === Number.MAX_SAFE_INTEGER ? fold.lastActive : state.startedAt,
787
+ ...state.endedAt === void 0 ? {} : { endedAt: state.endedAt }
788
+ }));
789
+ return fold;
790
+ }
791
+ /** Accumulate one ModelUsage into another (merge step of the incremental aggregator). */
792
+ function mergeUsageInto(acc, cell) {
793
+ acc.calls += cell.calls;
794
+ acc.input += cell.input;
795
+ acc.output += cell.output;
796
+ acc.cacheHit += cell.cacheHit;
797
+ acc.cacheMiss += cell.cacheMiss;
798
+ acc.cost += cell.cost;
799
+ }
800
+ /**
801
+ * Create the incremental usage aggregator.
623
802
  * @param persistence - the session persistence service.
624
803
  * @param options - aggregation tuning (e.g. subscription-plan providers).
625
- * @returns the usage-stats document (same shape the dashboard expects).
804
+ * @returns the aggregator holding the per-session fold cache.
626
805
  */
627
- async function aggregateUsage(persistence, options = {}) {
806
+ function createUsageAggregator(persistence, options = {}) {
628
807
  const subscriptionProviders = new Set(options.subscriptionProviders ?? DEFAULT_SUBSCRIPTION_PROVIDERS);
629
- const total = emptyUsage();
630
- const byModel = /* @__PURE__ */ new Map();
631
- const byDay = /* @__PURE__ */ new Map();
632
- const byModelDay = /* @__PURE__ */ new Map();
633
- for (const meta of await persistence.list()) {
634
- const { events } = await persistence.readFrom(meta.id, 0);
635
- let key = "other";
636
- let subscription = false;
637
- for (const event of events) {
638
- if (event.type === "request/header") {
639
- const { model, provider } = event.data.header.config;
640
- key = MODEL_KEY_ALIASES[model] ?? model;
641
- subscription = subscriptionProviders.has(provider);
808
+ const cache = /* @__PURE__ */ new Map();
809
+ let lastDoc;
810
+ let lastAt = 0;
811
+ /** 失效键:日志文件的 mtime+size;拿不到(后端无 locate / 文件丢失)时每次重折。 */
812
+ const stampOf = async (meta) => {
813
+ const location = persistence.locate?.(meta);
814
+ if (location === void 0) return null;
815
+ try {
816
+ const info = await stat(location.path);
817
+ return `${String(info.mtimeMs)}:${String(info.size)}`;
818
+ } catch {
819
+ return null;
820
+ }
821
+ };
822
+ return { async aggregate() {
823
+ const now = Date.now();
824
+ if (lastDoc !== void 0 && now - lastAt < 5e3) return lastDoc;
825
+ const metas = await persistence.list();
826
+ const seen = /* @__PURE__ */ new Set();
827
+ const folds = [];
828
+ for (const meta of metas) {
829
+ const id = String(meta.id);
830
+ seen.add(id);
831
+ const stamp = await stampOf(meta);
832
+ const hit = cache.get(id);
833
+ if (hit !== void 0 && stamp !== null && hit.stamp === stamp) {
834
+ folds.push({
835
+ meta,
836
+ fold: hit.fold
837
+ });
642
838
  continue;
643
839
  }
644
- if (event.type !== "assistant/message" || event.data.usage === void 0) continue;
645
- const modelKey = key;
646
- const day = dayStamp(event.time);
647
- foldUsage(total, event.data.usage, modelKey, subscription);
648
- foldUsage(usageCell(byModel, modelKey), event.data.usage, modelKey, subscription);
649
- foldUsage(usageCell(byDay, day), event.data.usage, modelKey, subscription);
650
- foldUsage(modelDayCell(byModelDay, day, modelKey), event.data.usage, modelKey, subscription);
840
+ const { events } = await persistence.readFrom(meta.id, 0);
841
+ const fold = foldSession(events, subscriptionProviders);
842
+ cache.set(id, {
843
+ stamp,
844
+ fold
845
+ });
846
+ folds.push({
847
+ meta,
848
+ fold
849
+ });
651
850
  }
652
- }
653
- const toRecord = (map) => Object.fromEntries(map);
654
- const toModelDayRecord = (map) => Object.fromEntries([...map].map(([day, models]) => [day, Object.fromEntries(models)]));
655
- return {
656
- version: 2,
657
- updatedAt: Date.now(),
658
- source: "session-logs",
659
- total,
660
- byModel: toRecord(byModel),
661
- byDay: toRecord(byDay),
662
- byDayModels: toModelDayRecord(byModelDay)
663
- };
851
+ for (const key of [...cache.keys()]) if (!seen.has(key)) cache.delete(key);
852
+ const total = emptyUsage();
853
+ const byModel = /* @__PURE__ */ new Map();
854
+ const byDay = /* @__PURE__ */ new Map();
855
+ const byDayModels = /* @__PURE__ */ new Map();
856
+ const planCalls = /* @__PURE__ */ new Map();
857
+ const sessionRows = [];
858
+ const turnRows = [];
859
+ const workspaceMap = /* @__PURE__ */ new Map();
860
+ for (const { meta, fold } of folds) {
861
+ const sessionId = String(meta.id);
862
+ mergeUsageInto(total, fold.total);
863
+ for (const [modelKey, cell] of fold.byModel) mergeUsageInto(usageCell(byModel, modelKey), cell);
864
+ for (const [day, cell] of fold.byDay) mergeUsageInto(usageCell(byDay, day), cell);
865
+ for (const [day, models] of fold.byDayModels) for (const [modelKey, cell] of models) mergeUsageInto(modelDayCell(byDayModels, day, modelKey), cell);
866
+ for (const [modelKey, count] of fold.planCalls) planCalls.set(modelKey, (planCalls.get(modelKey) ?? 0) + count);
867
+ for (const row of fold.turns) turnRows.push({
868
+ sessionId,
869
+ ...row
870
+ });
871
+ const wsName = workspaceNameOf(meta.cwd);
872
+ const ws = workspaceMap.get(wsName) ?? {
873
+ name: wsName,
874
+ calls: 0,
875
+ cost: 0,
876
+ input: 0,
877
+ output: 0,
878
+ lastActive: 0
879
+ };
880
+ ws.calls += fold.total.calls;
881
+ ws.cost += fold.total.cost;
882
+ ws.input += fold.total.input;
883
+ ws.output += fold.total.output;
884
+ ws.lastActive = Math.max(ws.lastActive, fold.lastActive);
885
+ workspaceMap.set(wsName, ws);
886
+ if (fold.total.calls > 0) sessionRows.push({
887
+ id: sessionId,
888
+ ...fold.title !== void 0 ? { title: fold.title } : {},
889
+ ...meta.cwd !== void 0 ? { cwd: meta.cwd } : {},
890
+ calls: fold.total.calls,
891
+ cost: fold.total.cost,
892
+ lastActive: fold.lastActive
893
+ });
894
+ }
895
+ sessionRows.sort((a, b) => b.cost - a.cost || b.lastActive - a.lastActive);
896
+ turnRows.sort((a, b) => b.startedAt - a.startedAt);
897
+ const workspaces = [...workspaceMap.values()].sort((a, b) => b.cost - a.cost || b.lastActive - a.lastActive);
898
+ const toRecord = (map) => {
899
+ const record = {};
900
+ for (const [key, cell] of map) if (planCalls.get(key) === cell.calls && cell.calls > 0) record[key] = {
901
+ ...cell,
902
+ plan: true
903
+ };
904
+ else record[key] = cell;
905
+ return record;
906
+ };
907
+ const toModelDayRecord = (map) => Object.fromEntries([...map].map(([day, models]) => [day, Object.fromEntries(models)]));
908
+ lastDoc = {
909
+ version: 3,
910
+ updatedAt: now,
911
+ source: "session-logs",
912
+ total,
913
+ byModel: toRecord(byModel),
914
+ byDay: toRecord(byDay),
915
+ byDayModels: toModelDayRecord(byDayModels),
916
+ bySession: sessionRows.slice(0, 100),
917
+ byTurn: turnRows.slice(0, 200),
918
+ byWorkspace: workspaces.slice(0, 100)
919
+ };
920
+ lastAt = now;
921
+ return lastDoc;
922
+ } };
664
923
  }
665
924
  //#endregion
666
925
  //#region lib/types/balance.js
@@ -910,6 +1169,398 @@ async function fetchLivePricing() {
910
1169
  };
911
1170
  }
912
1171
  //#endregion
1172
+ //#region lib/types/subscriptions.js
1173
+ /**
1174
+ * Subscription-plan quota polling (node half): how much of each coding/token
1175
+ * plan is left. The billing dashboard already exempts subscription providers
1176
+ * from per-token cost; this module surfaces the REMAINING quota so the user
1177
+ * sees plan headroom instead of a blank row.
1178
+ *
1179
+ * The panel shows only the plans the user actually configured: adapters with
1180
+ * a known quota API (Kimi, Z.ai, OpenCode Go) query the remaining amount;
1181
+ * other subscription providers the harness recognizes (volcengine / baidu /
1182
+ * qwen / xiaomi token plans, agent plans…) are identified and listed with a
1183
+ * "no quota API" marker rather than hidden. API keys come from the `llm-pi-ai`
1184
+ * settings namespace (`apiKeyEnv` refs) resolved through the credentials seam.
1185
+ */
1186
+ /** 空凭据:全部未配置时的初始值。 */
1187
+ const EMPTY_SUBSCRIPTION_KEYS = {
1188
+ kimiApiKey: "",
1189
+ zaiApiKey: "",
1190
+ opencodeApiKey: "",
1191
+ zaiRegion: "global"
1192
+ };
1193
+ /** 订阅类 provider 的显示名(未命中的回退为 id 本身)。 */
1194
+ const SUBSCRIPTION_DISPLAY_NAMES = {
1195
+ "kimi-coding": "Kimi For Coding",
1196
+ "zai-coding-cn": "Z.ai Coding Plan",
1197
+ "zai-coding": "Z.ai Coding Plan",
1198
+ "opencode": "OpenCode Plan",
1199
+ "opencode-go": "OpenCode Go",
1200
+ "qwen-token-plan": "通义 Token Plan",
1201
+ "qwen-token-plan-cn": "通义 Token Plan(国内)",
1202
+ "xiaomi-token-plan-ams": "小米 Token Plan(海外)",
1203
+ "xiaomi-token-plan-cn": "小米 Token Plan(国内)",
1204
+ "xiaomi-token-plan-sgp": "小米 Token Plan(新加坡)",
1205
+ "volcengine-token-plan": "火山引擎 Token Plan",
1206
+ "ark-token-plan": "火山方舟 Token Plan",
1207
+ "doubao-token-plan": "豆包 Token Plan",
1208
+ "ernie": "百度文心 Plan",
1209
+ "baidu": "百度文心 Plan",
1210
+ "wenxin": "百度文心 Plan",
1211
+ "minimax": "MiniMax Coding Plan"
1212
+ };
1213
+ /** 订阅类 provider id 判定:带 coding / agent-plan / token-plan 后缀,或已知订阅通道。 */
1214
+ const SUBSCRIPTION_ID_RE = /(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax)/i;
1215
+ /** 是否是订阅类 provider id(如 kimi-coding、xiaomi-token-plan-cn)。 */
1216
+ function isSubscriptionProviderId(providerId) {
1217
+ if (SUBSCRIPTION_ID_RE.test(providerId)) return true;
1218
+ return SUBSCRIPTION_DISPLAY_NAMES[providerId] !== void 0;
1219
+ }
1220
+ /** 适配器注册表:provider id → 收集器(displayName 同步映射)。 */
1221
+ const SUBSCRIPTION_ADAPTERS = {
1222
+ "kimi-coding": { collect: collectKimi },
1223
+ "zai-coding-cn": { collect: collectZai },
1224
+ "opencode": { collect: collectOpenCodeGo },
1225
+ "opencode-go": { collect: collectOpenCodeGo }
1226
+ };
1227
+ /** 有额度适配器的 provider id 集合(识别用)。 */
1228
+ const ADAPTER_PROVIDER_IDS = new Set(Object.keys(SUBSCRIPTION_ADAPTERS));
1229
+ /**
1230
+ * 从 llm-pi-ai 设置里识别订阅套餐:带订阅类 id 且配置了 apiKeyEnv 的 provider。
1231
+ * @param providers - the `providers` map of the llm-pi-ai settings namespace.
1232
+ * @returns identified plans in configuration order.
1233
+ */
1234
+ function identifySubscriptionPlans(providers) {
1235
+ const out = [];
1236
+ for (const [id, config] of Object.entries(providers ?? {})) {
1237
+ if (typeof config?.apiKeyEnv !== "string" || config.apiKeyEnv === "") continue;
1238
+ if (!isSubscriptionProviderId(id)) continue;
1239
+ out.push({
1240
+ provider: id,
1241
+ displayName: SUBSCRIPTION_DISPLAY_NAMES[id] ?? id,
1242
+ adapter: ADAPTER_PROVIDER_IDS.has(id),
1243
+ ...id === "zai-coding-cn" ? { region: "bigmodel-cn" } : {}
1244
+ });
1245
+ }
1246
+ return out;
1247
+ }
1248
+ const DEFAULT_TIMEOUT_MS = 15e3;
1249
+ /** Number, or null when the value is not a finite number (nor numeric string). */
1250
+ function numberOrNull(value) {
1251
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1252
+ if (typeof value === "string" && value.trim() !== "") {
1253
+ const parsed = Number(value);
1254
+ if (Number.isFinite(parsed)) return parsed;
1255
+ }
1256
+ return null;
1257
+ }
1258
+ /** Clamp a percentage to 0–100. */
1259
+ function clampPercent(value) {
1260
+ return value === null ? null : Math.max(0, Math.min(100, value));
1261
+ }
1262
+ /** Round to one decimal. */
1263
+ function round1(value) {
1264
+ return Math.round(value * 10) / 10;
1265
+ }
1266
+ /** Number → ISO string (seconds treated as epoch seconds, ms as epoch ms). */
1267
+ function toIso(value) {
1268
+ if (value === null || value === void 0 || value === "") return null;
1269
+ if (typeof value === "number" && Number.isFinite(value)) {
1270
+ const date = new Date(value < 2e12 ? value * 1e3 : value);
1271
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
1272
+ }
1273
+ const date = new Date(String(value));
1274
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
1275
+ }
1276
+ /** Map a fetch error to a stable status. */
1277
+ function statusOf(error) {
1278
+ if (error instanceof Error) {
1279
+ if (error.name === "TimeoutError" || error.name === "AbortError") return "unavailable";
1280
+ const status = error.httpStatus;
1281
+ if (status === 401 || status === 403) return "unauthorized";
1282
+ if (status === 429) return "rate-limited";
1283
+ if (status === 404) return "unavailable";
1284
+ }
1285
+ return "unavailable";
1286
+ }
1287
+ /** One JSON fetch with a timeout, mapping HTTP failures to typed errors. */
1288
+ async function requestJson(url, init, timeoutMs) {
1289
+ const response = await fetch(url, {
1290
+ ...init,
1291
+ signal: AbortSignal.timeout(timeoutMs)
1292
+ });
1293
+ if (!response.ok) {
1294
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
1295
+ error.httpStatus = response.status;
1296
+ throw error;
1297
+ }
1298
+ return await response.json();
1299
+ }
1300
+ /** Parse one Kimi limit window entry. */
1301
+ function kimiWindow(value, kind) {
1302
+ if (value === null || typeof value !== "object") return null;
1303
+ const record = value;
1304
+ const limit = numberOrNull(record.limit ?? record.total);
1305
+ const remaining = numberOrNull(record.remaining);
1306
+ if (limit === null || remaining === null || limit <= 0) return null;
1307
+ const usedPercent = round1(clampPercent((limit - remaining) / limit * 100) ?? 0);
1308
+ const resetsAt = toIso(record.resetTime ?? record.reset_time ?? record.resetsAt);
1309
+ return {
1310
+ kind,
1311
+ usedPercent,
1312
+ remainingPercent: round1(100 - usedPercent),
1313
+ remaining,
1314
+ ...resetsAt === null ? {} : { resetsAt }
1315
+ };
1316
+ }
1317
+ /** Parse a Kimi `/coding/v1/usages` body. */
1318
+ function parseKimi(body) {
1319
+ const record = body?.data ?? body ?? {};
1320
+ const session = (Array.isArray(record.limits) ? record.limits : []).map((entry) => kimiWindow(entry?.detail ?? entry, "session")).find((hit) => hit !== null) ?? null;
1321
+ const weekly = kimiWindow(record.usage, "weekly");
1322
+ const plan = typeof record.plan === "string" ? record.plan : typeof record.planName === "string" ? record.planName : void 0;
1323
+ return {
1324
+ ...plan !== void 0 && plan !== "" ? { plan } : {},
1325
+ windows: [session, weekly].filter((hit) => hit !== null)
1326
+ };
1327
+ }
1328
+ /** Collect the Kimi For Coding quota. */
1329
+ async function collectKimi(keys, config, timeoutMs) {
1330
+ const apiKey = keys.kimiApiKey.trim();
1331
+ const base = config.baseUrl ?? "https://api.kimi.com";
1332
+ if (apiKey === "") return {
1333
+ provider: config.provider,
1334
+ displayName: "Kimi For Coding",
1335
+ status: "not-configured",
1336
+ windows: []
1337
+ };
1338
+ try {
1339
+ const parsed = parseKimi(await requestJson(`${base}/coding/v1/usages`, { headers: {
1340
+ authorization: `Bearer ${apiKey}`,
1341
+ accept: "application/json"
1342
+ } }, timeoutMs));
1343
+ return {
1344
+ provider: config.provider,
1345
+ displayName: "Kimi For Coding",
1346
+ ...parsed.plan !== void 0 ? { plan: parsed.plan } : {},
1347
+ status: parsed.windows.length > 0 ? "ok" : "invalid-response",
1348
+ windows: parsed.windows
1349
+ };
1350
+ } catch (error) {
1351
+ return {
1352
+ provider: config.provider,
1353
+ displayName: "Kimi For Coding",
1354
+ status: statusOf(error),
1355
+ windows: []
1356
+ };
1357
+ }
1358
+ }
1359
+ /** Window length in minutes for a Z.ai limit row; null when unknown. */
1360
+ function zaiWindowMinutes(limit) {
1361
+ const unit = numberOrNull(limit.unit);
1362
+ const number = numberOrNull(limit.number);
1363
+ if (unit === null || number === null || number <= 0) return null;
1364
+ if (unit === 5) return number;
1365
+ if (unit === 3) return number * 60;
1366
+ if (unit === 1) return number * 24 * 60;
1367
+ if (unit === 6) return number * 7 * 24 * 60;
1368
+ return null;
1369
+ }
1370
+ /** Used percent for a Z.ai limit row. */
1371
+ function zaiUsedPercent(limit) {
1372
+ const total = numberOrNull(limit.usage);
1373
+ const remaining = numberOrNull(limit.remaining);
1374
+ const current = numberOrNull(limit.currentValue ?? limit.current_value);
1375
+ if (total !== null && total > 0) {
1376
+ const used = remaining === null ? current : current === null ? total - remaining : Math.max(total - remaining, current);
1377
+ if (used !== null) return clampPercent(Math.max(0, Math.min(total, used)) / total * 100);
1378
+ }
1379
+ return clampPercent(numberOrNull(limit.percentage ?? limit.usedPercent ?? limit.used_percent));
1380
+ }
1381
+ /** One Z.ai quota window row. */
1382
+ function zaiWindow(limit, kind, fallbackReset = null) {
1383
+ const usedPercent = zaiUsedPercent(limit);
1384
+ if (usedPercent === null) return null;
1385
+ const resetsAt = toIso(limit.nextResetTime ?? limit.next_reset_time) ?? fallbackReset;
1386
+ return {
1387
+ kind,
1388
+ usedPercent: round1(usedPercent),
1389
+ remainingPercent: round1(100 - usedPercent),
1390
+ ...resetsAt === null ? {} : { resetsAt }
1391
+ };
1392
+ }
1393
+ /** Parse Z.ai quota + subscription bodies into windows. */
1394
+ function parseZai(quotaBody, subscriptionBody) {
1395
+ const quota = quotaBody ?? {};
1396
+ const limits = Array.isArray(quota.data?.limits) ? quota.data.limits : [];
1397
+ const tokenLimits = limits.filter((entry) => {
1398
+ const record = entry;
1399
+ const type = String(record.type ?? record.limit_type ?? "").toUpperCase();
1400
+ return (type === "TOKENS_LIMIT" || type === "CREDIT_LIMIT") && zaiUsedPercent(record) !== null;
1401
+ }).sort((a, b) => (zaiWindowMinutes(a) ?? Number.MAX_SAFE_INTEGER) - (zaiWindowMinutes(b) ?? Number.MAX_SAFE_INTEGER));
1402
+ const timeLimit = limits.find((entry) => {
1403
+ const record = entry;
1404
+ return String(record.type ?? record.limit_type ?? "").toUpperCase() === "TIME_LIMIT" && zaiUsedPercent(record) !== null;
1405
+ });
1406
+ const first = tokenLimits[0];
1407
+ const session = tokenLimits.length >= 2 ? first : first !== void 0 && zaiWindowMinutes(first) !== null && (zaiWindowMinutes(first) ?? 0) <= 360 ? first : void 0;
1408
+ const weekly = tokenLimits.length >= 2 ? tokenLimits[tokenLimits.length - 1] : session === void 0 ? first : void 0;
1409
+ const subscriptionRow = subscriptionBody?.data;
1410
+ const renewAt = toIso(Array.isArray(subscriptionRow) ? subscriptionRow[0]?.next_renew_time ?? subscriptionRow[0]?.nextRenewTime : void 0);
1411
+ const row = Array.isArray(subscriptionRow) ? subscriptionRow[0] : void 0;
1412
+ let plan = "GLM Coding Plan";
1413
+ for (const source of [row, quota.data]) {
1414
+ if (source === null || typeof source !== "object") continue;
1415
+ const record = source;
1416
+ for (const key of [
1417
+ "product_name",
1418
+ "productName",
1419
+ "plan_name",
1420
+ "planName",
1421
+ "package_name",
1422
+ "packageName",
1423
+ "level"
1424
+ ]) {
1425
+ const value = record[key];
1426
+ if (typeof value === "string" && value.trim() !== "") {
1427
+ plan = value.trim();
1428
+ break;
1429
+ }
1430
+ }
1431
+ if (plan !== "GLM Coding Plan") break;
1432
+ }
1433
+ return {
1434
+ plan,
1435
+ windows: [
1436
+ session === void 0 ? null : zaiWindow(session, "session"),
1437
+ weekly === void 0 ? null : zaiWindow(weekly, "weekly"),
1438
+ timeLimit === void 0 ? null : zaiWindow(timeLimit, "billing", renewAt)
1439
+ ].filter((hit) => hit !== null)
1440
+ };
1441
+ }
1442
+ /** Collect the Z.ai Coding Plan quota. */
1443
+ async function collectZai(keys, config, timeoutMs) {
1444
+ const apiKey = keys.zaiApiKey.trim();
1445
+ const host = (config.region ?? keys.zaiRegion ?? "global") === "bigmodel-cn" ? "https://open.bigmodel.cn" : "https://api.z.ai";
1446
+ if (apiKey === "") return {
1447
+ provider: config.provider,
1448
+ displayName: "Z.ai Coding Plan",
1449
+ status: "not-configured",
1450
+ windows: []
1451
+ };
1452
+ try {
1453
+ const init = { headers: {
1454
+ authorization: apiKey,
1455
+ accept: "application/json"
1456
+ } };
1457
+ const quota = await requestJson(`${host}/api/monitor/usage/quota/limit`, init, timeoutMs);
1458
+ let subscription = null;
1459
+ try {
1460
+ subscription = await requestJson(`${host}/api/biz/subscription/list`, init, timeoutMs);
1461
+ } catch {}
1462
+ const parsed = parseZai(quota, subscription);
1463
+ return {
1464
+ provider: config.provider,
1465
+ displayName: "Z.ai Coding Plan",
1466
+ plan: parsed.plan,
1467
+ status: parsed.windows.length > 0 ? "ok" : "invalid-response",
1468
+ windows: parsed.windows
1469
+ };
1470
+ } catch (error) {
1471
+ return {
1472
+ provider: config.provider,
1473
+ displayName: "Z.ai Coding Plan",
1474
+ status: statusOf(error),
1475
+ windows: []
1476
+ };
1477
+ }
1478
+ }
1479
+ /** Parse one OpenCode Go window object. */
1480
+ function goWindow(value, kind) {
1481
+ if (value === null || typeof value !== "object") return null;
1482
+ const record = value;
1483
+ const percentSource = record.usagePercent ?? record.usedPercent ?? record.percentUsed ?? record.percentage ?? record.percent;
1484
+ let usedPercent = clampPercent(numberOrNull(percentSource));
1485
+ if (usedPercent === null) {
1486
+ const used = numberOrNull(record.used ?? record.consumed);
1487
+ const limit = numberOrNull(record.limit ?? record.total ?? record.quota);
1488
+ if (used !== null && limit !== null && limit > 0) usedPercent = clampPercent(used / limit * 100);
1489
+ }
1490
+ if (usedPercent === null) return null;
1491
+ if (usedPercent <= 1 && usedPercent >= 0 && record.percent === void 0 && percentSource !== void 0) usedPercent *= 100;
1492
+ const resetSeconds = numberOrNull(record.resetInSec ?? record.resetInSeconds ?? record.resetSeconds);
1493
+ const resetsAt = resetSeconds === null ? toIso(record.resetAt ?? record.resetsAt ?? record.nextReset) : new Date(Date.now() + Math.max(0, resetSeconds) * 1e3).toISOString();
1494
+ return {
1495
+ kind,
1496
+ usedPercent: round1(clampPercent(usedPercent) ?? 0),
1497
+ remainingPercent: round1(100 - (clampPercent(usedPercent) ?? 0)),
1498
+ ...resetsAt === null ? {} : { resetsAt }
1499
+ };
1500
+ }
1501
+ /** Parse the OpenCode Go Bearer endpoint body. */
1502
+ function parseOpenCodeGoApi(body) {
1503
+ const usage = body?.usage ?? body;
1504
+ if (usage === null || typeof usage !== "object") return [];
1505
+ const record = usage;
1506
+ return [
1507
+ goWindow(record.rolling, "session"),
1508
+ goWindow(record.weekly, "weekly"),
1509
+ goWindow(record.monthly, "monthly")
1510
+ ].filter((hit) => hit !== null);
1511
+ }
1512
+ /** Collect the OpenCode Go quota. */
1513
+ async function collectOpenCodeGo(keys, config, timeoutMs) {
1514
+ const apiKey = keys.opencodeApiKey.trim();
1515
+ const base = config.baseUrl ?? "https://opencode.ai";
1516
+ if (apiKey === "") return {
1517
+ provider: config.provider,
1518
+ displayName: "OpenCode Go",
1519
+ status: "not-configured",
1520
+ windows: []
1521
+ };
1522
+ try {
1523
+ const windows = parseOpenCodeGoApi(await requestJson(`${base}/zen/go/v1/usage`, { headers: {
1524
+ authorization: `Bearer ${apiKey}`,
1525
+ accept: "application/json"
1526
+ } }, timeoutMs));
1527
+ return {
1528
+ provider: config.provider,
1529
+ displayName: "OpenCode Go",
1530
+ status: windows.length > 0 ? "ok" : "invalid-response",
1531
+ windows
1532
+ };
1533
+ } catch (error) {
1534
+ return {
1535
+ provider: config.provider,
1536
+ displayName: "OpenCode Go",
1537
+ status: statusOf(error),
1538
+ windows: []
1539
+ };
1540
+ }
1541
+ }
1542
+ /**
1543
+ * Collect quota for the given plans concurrently (adapter-backed plans only;
1544
+ * identified plans without an adapter are surfaced by the caller as "no
1545
+ * quota API" rows).
1546
+ * @param keys - the API keys from the llm-pi-ai settings namespace.
1547
+ * @param plans - adapter-backed plans to poll; empty by default.
1548
+ * @param timeoutMs - per-request timeout; defaults to 15s.
1549
+ * @returns the quotas in plan order (unknown providers degrade to `unavailable`).
1550
+ */
1551
+ async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOUT_MS) {
1552
+ return await Promise.all(plans.map((plan) => {
1553
+ const adapter = SUBSCRIPTION_ADAPTERS[plan.provider];
1554
+ if (adapter === void 0) return Promise.resolve({
1555
+ provider: plan.provider,
1556
+ displayName: plan.provider,
1557
+ status: "unavailable",
1558
+ windows: []
1559
+ });
1560
+ return adapter.collect(keys, plan, timeoutMs);
1561
+ }));
1562
+ }
1563
+ //#endregion
913
1564
  //#region lib/types/index.js
914
1565
  /**
915
1566
  * Usage billing surface plugin, node half.
@@ -924,20 +1575,78 @@ async function fetchLivePricing() {
924
1575
  */
925
1576
  /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
926
1577
  const PRICING_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
1578
+ /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
1579
+ const SUBSCRIPTION_CACHE_MS = 300 * 1e3;
927
1580
  /** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
928
1581
  const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
929
- /** Required services: the web server and the persisted session log store. */
1582
+ /** Required services: the web server, the persisted session log store, and user settings. */
930
1583
  const inject = [
931
1584
  "webServer",
932
1585
  "sessionPersistence",
933
- "credentials"
1586
+ "credentials",
1587
+ "settings"
934
1588
  ];
935
1589
  /**
1590
+ * 订阅 provider id(llm-pi-ai 设置键)→ billing 适配器 key 的映射。
1591
+ * 复用 dsh 既有的 llm-pi-ai provider 配置(apiKeyEnv 引用),不引入新配置面。
1592
+ */
1593
+ const SUBSCRIPTION_KEY_SOURCES = [
1594
+ {
1595
+ provider: "kimi-coding",
1596
+ key: "kimiApiKey"
1597
+ },
1598
+ {
1599
+ provider: "zai-coding-cn",
1600
+ key: "zaiApiKey"
1601
+ },
1602
+ {
1603
+ provider: "opencode",
1604
+ key: "opencodeApiKey"
1605
+ },
1606
+ {
1607
+ provider: "opencode-go",
1608
+ key: "opencodeApiKey"
1609
+ }
1610
+ ];
1611
+ /**
1612
+ * 解析订阅适配器需要的 API Key:从 llm-pi-ai 设置的 `providers.<id>.apiKeyEnv`
1613
+ * 读引用(如 kimi-coding → KIMI_CODING_API_KEY),再经凭据 seam 解析成实际值。
1614
+ * 同时识别出用户配置了 key 的订阅套餐(供面板只显示已识别的)。
1615
+ * @param settings - the settings service (reads the llm-pi-ai namespace).
1616
+ * @param credentials - the credentials service (resolves the env refs).
1617
+ */
1618
+ async function resolveSubscriptionKeys(settings, credentials) {
1619
+ const keys = { ...EMPTY_SUBSCRIPTION_KEYS };
1620
+ let providers;
1621
+ try {
1622
+ providers = (settings.describe({ redactSecrets: true }).find((descriptor) => descriptor.ns === "llm-pi-ai")?.value)?.providers;
1623
+ } catch {
1624
+ return {
1625
+ keys,
1626
+ identified: []
1627
+ };
1628
+ }
1629
+ for (const { provider, key } of SUBSCRIPTION_KEY_SOURCES) {
1630
+ const env = providers?.[provider]?.apiKeyEnv;
1631
+ if (typeof env !== "string" || env === "") continue;
1632
+ try {
1633
+ const resolved = await credentials.resolve(credentialRef(env));
1634
+ if (resolved?.value !== void 0 && resolved.value !== "") keys[key] = resolved.value;
1635
+ } catch {}
1636
+ }
1637
+ if (providers?.["zai-coding-cn"]?.apiKeyEnv !== void 0 && keys.zaiApiKey !== "") keys.zaiRegion = "bigmodel-cn";
1638
+ return {
1639
+ keys,
1640
+ identified: identifySubscriptionPlans(providers)
1641
+ };
1642
+ }
1643
+ /**
936
1644
  * Host plugin body: serve real aggregated usage to the browser dashboard.
937
1645
  * @param ctx - host context carrying webServer and sessionPersistence.
938
1646
  * @param config - optional statsPath override.
939
1647
  */
940
1648
  function apply(ctx, config = {}) {
1649
+ const aggregator = createUsageAggregator(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } });
941
1650
  const cwd = process.cwd();
942
1651
  const candidates = [
943
1652
  config.statsPath,
@@ -975,19 +1684,59 @@ function apply(ctx, config = {}) {
975
1684
  res.end(JSON.stringify({ balances }));
976
1685
  }
977
1686
  }), "usage-billing: balance route");
1687
+ let quotaCache = {
1688
+ at: 0,
1689
+ quotas: []
1690
+ };
1691
+ const refreshQuotas = async () => {
1692
+ const { keys, identified } = await resolveSubscriptionKeys(ctx.settings, ctx.credentials);
1693
+ const rows = [...await collectSubscriptions(keys, identified.filter((item) => item.adapter).map((item) => ({
1694
+ provider: item.provider,
1695
+ ...item.region === void 0 ? {} : { region: item.region }
1696
+ })))];
1697
+ for (const item of identified) if (!item.adapter) rows.push({
1698
+ provider: item.provider,
1699
+ displayName: item.displayName,
1700
+ status: "ok",
1701
+ windows: []
1702
+ });
1703
+ quotaCache = {
1704
+ at: Date.now(),
1705
+ quotas: rows
1706
+ };
1707
+ };
1708
+ ctx.effect(() => ctx.webServer.register({
1709
+ kind: "exact",
1710
+ path: "/api/billing/subscriptions",
1711
+ handler: async (_req, res) => {
1712
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1713
+ if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) await refreshQuotas();
1714
+ res.end(JSON.stringify({ quotas: quotaCache.quotas }));
1715
+ }
1716
+ }), "usage-billing: subscriptions route");
978
1717
  ctx.effect(() => ctx.webServer.register({
979
1718
  kind: "exact",
980
1719
  path: "/api/billing/usage-stats",
981
1720
  handler: async (_req, res) => {
982
1721
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
983
1722
  try {
984
- res.end(JSON.stringify(await aggregateUsage(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } })));
1723
+ const stats = await aggregator.aggregate();
1724
+ const injected = {
1725
+ ...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
1726
+ ...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
1727
+ };
1728
+ res.end(JSON.stringify(Object.keys(injected).length === 0 ? stats : {
1729
+ ...stats,
1730
+ ...injected
1731
+ }));
985
1732
  return;
986
1733
  } catch {}
987
1734
  for (const candidate of candidates) try {
988
1735
  const text = await readFile(candidate, "utf8");
989
- JSON.parse(text);
990
- res.end(text);
1736
+ const doc = JSON.parse(text);
1737
+ if (config.monthlyBudget !== void 0) doc["budget"] = config.monthlyBudget;
1738
+ if (config.lowBalanceThreshold !== void 0) doc["lowBalanceThreshold"] = config.lowBalanceThreshold;
1739
+ res.end(JSON.stringify(doc));
991
1740
  return;
992
1741
  } catch {}
993
1742
  res.end(JSON.stringify({ error: "usage stats unavailable" }));
@@ -995,4 +1744,4 @@ function apply(ctx, config = {}) {
995
1744
  }), "usage-billing: usage-stats route");
996
1745
  }
997
1746
  //#endregion
998
- export { apply, inject };
1747
+ export { apply, inject, resolveSubscriptionKeys };