@kenz1117/dsh-ui-usage-billing 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -9,6 +9,22 @@ 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
+ * 高峰时段判定(北京时间,UTC+8,无夏令时):09:00–12:00、14:00–18:00。
13
+ * @param beijingHour - 北京时间的小时数(0–23)。
14
+ */
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";
26
+ }
27
+ /**
12
28
  * Built-in catalog of current mainstream models as of 2026-08-16, priced from
13
29
  * each provider's official price page. Domestic providers are OpenAI-API
14
30
  * compatible and publish RMB prices directly; overseas providers publish USD
@@ -42,6 +58,24 @@ const MODEL_CATALOG = [
42
58
  },
43
59
  peakHours: "09:00-12:00 / 14:00-18:00"
44
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
+ },
45
79
  {
46
80
  key: "pro",
47
81
  name: "DeepSeek V4 Pro",
@@ -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");
@@ -524,6 +579,21 @@ function computeCost(entry, buckets, peakShare = DEFAULT_PEAK_SHARE) {
524
579
  const off = entry.price.offPeak === void 0 ? peak : priceBandCost(entry.price.offPeak, buckets, entry.price.currency);
525
580
  return peak * peakShare + off * (1 - peakShare);
526
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
+ }
527
597
  //#endregion
528
598
  //#region lib/types/aggregate.js
529
599
  /**
@@ -538,24 +608,6 @@ function computeCost(entry, buckets, peakShare = DEFAULT_PEAK_SHARE) {
538
608
  * handle is injected, so the fold is unit-testable without a host.
539
609
  */
540
610
  /**
541
- * Real provider model ids map to their billing-catalog keys. Unknown ids stay
542
- * as-is and price zero (they are not in the catalog; subscription-plan routes
543
- * like kimi-coding / token plans fall here and therefore cost nothing).
544
- */
545
- const MODEL_KEY_ALIASES = {
546
- "deepseek-v4-flash": "flash",
547
- "deepseek-v4-pro": "pro",
548
- "glm-5.2": "glm",
549
- "qwen3.8-max": "qwen-3.8-max",
550
- "qwen3.7-max": "qwen-max",
551
- "qwen-max": "qwen-max",
552
- "hunyuan-t1": "hunyuan-t1",
553
- "step-3.7-flash": "step",
554
- "seed-2.0-mini": "doubao-mini",
555
- "k3": "kimi-k3",
556
- "kimi-k3": "kimi-k3"
557
- };
558
- /**
559
611
  * 走订阅套餐(coding / token plan / opencode 订阅)的 provider id:这些通道的
560
612
  * 调用按套餐计费,不再按 token 计费,因此即使模型 id 与计费表撞名也一律豁免。
561
613
  * 与 pi-ai 内置提供方对齐(含各地区变体:qwen/xiaomi 的 token-plan、opencode 与
@@ -592,8 +644,9 @@ function emptyUsage() {
592
644
  * @param usage - the provider-reported usage of one call.
593
645
  * @param key - the billing-catalog key this call belongs to.
594
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.
595
648
  */
596
- function foldUsage(acc, usage, key, subscription) {
649
+ function foldUsage(acc, usage, key, subscription, timeMs) {
597
650
  const cacheHit = usage.cacheReadTokens ?? 0;
598
651
  const cacheMiss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
599
652
  acc.calls += 1;
@@ -601,12 +654,12 @@ function foldUsage(acc, usage, key, subscription) {
601
654
  acc.output += usage.outputTokens;
602
655
  acc.cacheHit += cacheHit;
603
656
  acc.cacheMiss += cacheMiss;
604
- if (!subscription && MODEL_CATALOG.some((entry) => entry.key === key)) acc.cost += computeCost(modelOf(key), {
657
+ if (!subscription && MODEL_CATALOG.some((entry) => entry.key === key)) acc.cost += computeCostAt(modelOf(key), {
605
658
  input: cacheHit + cacheMiss,
606
659
  cacheHit,
607
660
  cacheMiss,
608
661
  output: usage.outputTokens
609
- });
662
+ }, timeMs);
610
663
  }
611
664
  /** Local-time date stamp (the host runs in the user's timezone). */
612
665
  function dayStamp(time) {
@@ -614,6 +667,11 @@ function dayStamp(time) {
614
667
  const pad = (n) => String(n).padStart(2, "0");
615
668
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
616
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
+ }
617
675
  /** Get-or-create one model cell inside a usage map (avoids non-null assertions). */
618
676
  function usageCell(map, key) {
619
677
  const existing = map.get(key);
@@ -631,9 +689,27 @@ function modelDayCell(map, day, modelKey) {
631
689
  }
632
690
  return usageCell(models, modelKey);
633
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
+ }
634
709
  /**
635
710
  * Fold one session's events into a {@link SessionFold}. 每个 LLM 调用归属到
636
- * 其前置 request/header 记录的模型;同时提取最新会话标题与最后活跃时间。
711
+ * 其前置 request/header 记录的模型;同时提取最新会话标题、最后活跃时间,
712
+ * 并按轮次折叠每轮费用明细(turn/start → turn/end;调用按 (turn) 归组)。
637
713
  * @param events - the session's persisted events in log order.
638
714
  * @param subscriptionProviders - provider ids billed through subscription plans.
639
715
  * @returns the per-session fold (cached by the incremental aggregator).
@@ -645,10 +721,12 @@ function foldSession(events, subscriptionProviders) {
645
721
  byDay: /* @__PURE__ */ new Map(),
646
722
  byDayModels: /* @__PURE__ */ new Map(),
647
723
  planCalls: /* @__PURE__ */ new Map(),
724
+ turns: [],
648
725
  lastActive: 0
649
726
  };
650
727
  let key = "other";
651
728
  let subscription = false;
729
+ const turns = /* @__PURE__ */ new Map();
652
730
  for (const event of events) {
653
731
  fold.lastActive = Math.max(fold.lastActive, event.time);
654
732
  if (event.type === "session/title") {
@@ -656,6 +734,17 @@ function foldSession(events, subscriptionProviders) {
656
734
  if (typeof title === "string" && title.length > 0) fold.title = title;
657
735
  continue;
658
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
+ }
659
748
  if (event.type === "request/header") {
660
749
  const { model, provider } = event.data.header.config;
661
750
  key = MODEL_KEY_ALIASES[model] ?? model;
@@ -667,12 +756,36 @@ function foldSession(events, subscriptionProviders) {
667
756
  if (usage === void 0) continue;
668
757
  const modelKey = key;
669
758
  const day = dayStamp(event.time);
670
- foldUsage(fold.total, usage, modelKey, subscription);
671
- foldUsage(usageCell(fold.byModel, modelKey), usage, modelKey, subscription);
672
- foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription);
673
- foldUsage(modelDayCell(fold.byDayModels, day, modelKey), usage, modelKey, subscription);
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);
674
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;
675
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
+ }));
676
789
  return fold;
677
790
  }
678
791
  /** Accumulate one ModelUsage into another (merge step of the incremental aggregator). */
@@ -742,14 +855,36 @@ function createUsageAggregator(persistence, options = {}) {
742
855
  const byDayModels = /* @__PURE__ */ new Map();
743
856
  const planCalls = /* @__PURE__ */ new Map();
744
857
  const sessionRows = [];
858
+ const turnRows = [];
859
+ const workspaceMap = /* @__PURE__ */ new Map();
745
860
  for (const { meta, fold } of folds) {
861
+ const sessionId = String(meta.id);
746
862
  mergeUsageInto(total, fold.total);
747
863
  for (const [modelKey, cell] of fold.byModel) mergeUsageInto(usageCell(byModel, modelKey), cell);
748
864
  for (const [day, cell] of fold.byDay) mergeUsageInto(usageCell(byDay, day), cell);
749
865
  for (const [day, models] of fold.byDayModels) for (const [modelKey, cell] of models) mergeUsageInto(modelDayCell(byDayModels, day, modelKey), cell);
750
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);
751
886
  if (fold.total.calls > 0) sessionRows.push({
752
- id: String(meta.id),
887
+ id: sessionId,
753
888
  ...fold.title !== void 0 ? { title: fold.title } : {},
754
889
  ...meta.cwd !== void 0 ? { cwd: meta.cwd } : {},
755
890
  calls: fold.total.calls,
@@ -758,6 +893,8 @@ function createUsageAggregator(persistence, options = {}) {
758
893
  });
759
894
  }
760
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);
761
898
  const toRecord = (map) => {
762
899
  const record = {};
763
900
  for (const [key, cell] of map) if (planCalls.get(key) === cell.calls && cell.calls > 0) record[key] = {
@@ -769,14 +906,16 @@ function createUsageAggregator(persistence, options = {}) {
769
906
  };
770
907
  const toModelDayRecord = (map) => Object.fromEntries([...map].map(([day, models]) => [day, Object.fromEntries(models)]));
771
908
  lastDoc = {
772
- version: 2,
909
+ version: 3,
773
910
  updatedAt: now,
774
911
  source: "session-logs",
775
912
  total,
776
913
  byModel: toRecord(byModel),
777
914
  byDay: toRecord(byDay),
778
915
  byDayModels: toModelDayRecord(byDayModels),
779
- bySession: sessionRows.slice(0, 100)
916
+ bySession: sessionRows.slice(0, 100),
917
+ byTurn: turnRows.slice(0, 200),
918
+ byWorkspace: workspaces.slice(0, 100)
780
919
  };
781
920
  lastAt = now;
782
921
  return lastDoc;
@@ -788,15 +927,23 @@ function createUsageAggregator(persistence, options = {}) {
788
927
  * Account-balance queries for the billing dashboard.
789
928
  *
790
929
  * Only providers with a public balance endpoint can report one. Today that is
791
- * DeepSeek (`GET https://api.deepseek.com/user/balance`, Bearer 鉴权); the
792
- * other mainstream providers (OpenAI, 智谱, 通义, Kimi…) expose no standard
793
- * balance API, so their rows in the model table show an unavailable state.
794
- * The lookup map below is the extension point for future providers.
930
+ * DeepSeek (`GET https://api.deepseek.com/user/balance`) and Moonshot/Kimi
931
+ * (`GET https://api.moonshot.cn/v1/users/me/balance`), both Bearer 鉴权 with a
932
+ * documented JSON shape; the other mainstream providers expose no standard
933
+ * balance API (or require a non-Bearer auth flow), so their rows in the model
934
+ * table show an unavailable state. The lookup map below is the extension point
935
+ * for future providers.
936
+ *
937
+ * API keys are read from the `llm-pi-ai` settings namespace (`providers.<id>.apiKeyEnv`),
938
+ * the same source the subscription adapter uses, so a deployment configures a
939
+ * provider's key once and every surface reuses it.
795
940
  */
796
941
  /** Abort a balance fetch when the upstream hangs beyond this budget. */
797
942
  const FETCH_TIMEOUT_MS$1 = 8e3;
798
943
  /** DeepSeek 官方余额接口(官方文档 api-docs.deepseek.com/api/get-user-balance)。 */
799
944
  const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
945
+ /** Moonshot/Kimi 官方余额接口(platform.kimi.com/docs/api/balance)。 */
946
+ const MOONSHOT_BALANCE_URL = "https://api.moonshot.cn/v1/users/me/balance";
800
947
  /** 数字归一化:接口返回的余额是字符串(如 `"110.00"`),统一转 number。 */
801
948
  function toNumber(value) {
802
949
  if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -806,22 +953,28 @@ function toNumber(value) {
806
953
  }
807
954
  }
808
955
  /**
809
- * Query the DeepSeek account balance through the configured credential.
956
+ * Fetch a Bearer-protected balance endpoint and normalize the HTTP outcome into
957
+ * a shared {@link ProviderBalance} row. Each provider supplies its own
958
+ * response parser for the success body.
810
959
  * @param ctx - host context carrying the credentials seam.
811
- * @param apiKeyEnv - credential reference resolving the DeepSeek API key.
960
+ * @param url - the balance endpoint.
961
+ * @param apiKeyEnv - credential reference resolving the API key.
962
+ * @param provider - the provider id (matches the model-table vendor display name).
963
+ * @param displayName - human-readable provider name.
964
+ * @param parse - maps a success JSON body to the balance row fields.
812
965
  * @returns the balance row, or an error row when the key/endpoint misbehaves.
813
966
  */
814
- async function queryDeepSeek(ctx, apiKeyEnv) {
967
+ async function queryBearerBalance(ctx, url, apiKeyEnv, provider, displayName, parse) {
815
968
  const hit = await ctx.credentials.resolve(credentialRef(apiKeyEnv));
816
969
  if (hit === void 0) return {
817
- provider: "deepseek",
818
- displayName: "DeepSeek",
970
+ provider,
971
+ displayName,
819
972
  error: "unconfigured"
820
973
  };
821
974
  const controller = new AbortController();
822
975
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
823
976
  try {
824
- const response = await fetch(DEEPSEEK_BALANCE_URL, {
977
+ const response = await fetch(url, {
825
978
  headers: {
826
979
  accept: "application/json",
827
980
  authorization: `Bearer ${hit.value}`
@@ -829,22 +982,41 @@ async function queryDeepSeek(ctx, apiKeyEnv) {
829
982
  signal: controller.signal
830
983
  });
831
984
  if (response.status === 401 || response.status === 403) return {
832
- provider: "deepseek",
833
- displayName: "DeepSeek",
985
+ provider,
986
+ displayName,
834
987
  error: "unauthorized"
835
988
  };
836
989
  if (!response.ok) return {
837
- provider: "deepseek",
838
- displayName: "DeepSeek",
990
+ provider,
991
+ displayName,
839
992
  error: "unreachable"
840
993
  };
841
- const data = await response.json();
842
- const info = (Array.isArray(data.balance_infos) ? data.balance_infos : [])[0];
994
+ return parse(await response.json());
995
+ } catch {
996
+ return {
997
+ provider,
998
+ displayName,
999
+ error: "unreachable"
1000
+ };
1001
+ } finally {
1002
+ clearTimeout(timer);
1003
+ }
1004
+ }
1005
+ /**
1006
+ * Query the DeepSeek account balance.
1007
+ * @param ctx - host context carrying the credentials seam.
1008
+ * @param apiKeyEnv - credential reference resolving the DeepSeek API key.
1009
+ * @returns the balance row, or an error row when the key/endpoint misbehaves.
1010
+ */
1011
+ function queryDeepSeek(ctx, apiKeyEnv) {
1012
+ return queryBearerBalance(ctx, DEEPSEEK_BALANCE_URL, apiKeyEnv, "deepseek", "DeepSeek", (data) => {
1013
+ const doc = data;
1014
+ const info = (Array.isArray(doc.balance_infos) ? doc.balance_infos : [])[0];
843
1015
  const currency = typeof info?.currency === "string" ? info.currency : void 0;
844
1016
  const totalBalance = toNumber(info?.total_balance);
845
1017
  const grantedBalance = toNumber(info?.granted_balance);
846
1018
  const toppedUpBalance = toNumber(info?.topped_up_balance);
847
- const isAvailable = typeof data.is_available === "boolean" ? data.is_available : void 0;
1019
+ const isAvailable = typeof doc.is_available === "boolean" ? doc.is_available : void 0;
848
1020
  return {
849
1021
  provider: "deepseek",
850
1022
  displayName: "DeepSeek",
@@ -854,28 +1026,57 @@ async function queryDeepSeek(ctx, apiKeyEnv) {
854
1026
  ...toppedUpBalance !== void 0 ? { toppedUpBalance } : {},
855
1027
  ...isAvailable !== void 0 ? { isAvailable } : {}
856
1028
  };
857
- } catch {
1029
+ });
1030
+ }
1031
+ /**
1032
+ * Query the Moonshot/Kimi account balance.
1033
+ * @param ctx - host context carrying the credentials seam.
1034
+ * @param apiKeyEnv - credential reference resolving the Moonshot API key.
1035
+ * @returns the balance row, or an error row when the key/endpoint misbehaves.
1036
+ */
1037
+ function queryMoonshot(ctx, apiKeyEnv) {
1038
+ return queryBearerBalance(ctx, MOONSHOT_BALANCE_URL, apiKeyEnv, "月之暗面", "月之暗面", (data) => {
1039
+ const doc = data;
1040
+ const totalBalance = toNumber(doc.data?.available_balance);
1041
+ const grantedBalance = toNumber(doc.data?.voucher_balance);
1042
+ const toppedUpBalance = toNumber(doc.data?.cash_balance);
858
1043
  return {
859
- provider: "deepseek",
860
- displayName: "DeepSeek",
861
- error: "unreachable"
1044
+ provider: "月之暗面",
1045
+ displayName: "月之暗面",
1046
+ currency: "CNY",
1047
+ ...totalBalance !== void 0 ? { totalBalance } : {},
1048
+ ...grantedBalance !== void 0 ? { grantedBalance } : {},
1049
+ ...toppedUpBalance !== void 0 ? { toppedUpBalance } : {}
862
1050
  };
863
- } finally {
864
- clearTimeout(timer);
865
- }
1051
+ });
866
1052
  }
867
1053
  const QUERIERS = [{
868
- provider: "deepseek",
1054
+ route: "deepseek",
1055
+ displayName: "deepseek",
869
1056
  querier: queryDeepSeek
1057
+ }, {
1058
+ route: "moonshot",
1059
+ displayName: "月之暗面",
1060
+ querier: queryMoonshot
870
1061
  }];
871
1062
  /**
872
- * Query every configured provider's account balance.
1063
+ * Query every configured provider's account balance. A provider is queried only
1064
+ * when its llm-pi-ai route has an `apiKeyEnv`; absent routes answer
1065
+ * `unconfigured` so the dashboard shows a stable state instead of dropping the row.
873
1066
  * @param ctx - host context carrying the credentials seam.
874
- * @param balanceApiKeyEnv - credential reference for the DeepSeek key.
1067
+ * @param providers - the llm-pi-ai providers dict (`<route> → { apiKeyEnv? }`).
875
1068
  * @returns the balance rows (one per provider).
876
1069
  */
877
- async function queryBalances(ctx, balanceApiKeyEnv) {
878
- return await Promise.all(QUERIERS.map(({ querier }) => querier(ctx, balanceApiKeyEnv)));
1070
+ async function queryBalances(ctx, providers) {
1071
+ return await Promise.all(QUERIERS.map(({ route, querier, displayName }) => {
1072
+ const env = providers[route]?.apiKeyEnv;
1073
+ if (typeof env !== "string" || env === "") return Promise.resolve({
1074
+ provider: displayName,
1075
+ displayName,
1076
+ error: "unconfigured"
1077
+ });
1078
+ return querier(ctx, env);
1079
+ }));
879
1080
  }
880
1081
  //#endregion
881
1082
  //#region lib/types/pricing-fetch.js
@@ -1030,6 +1231,398 @@ async function fetchLivePricing() {
1030
1231
  };
1031
1232
  }
1032
1233
  //#endregion
1234
+ //#region lib/types/subscriptions.js
1235
+ /**
1236
+ * Subscription-plan quota polling (node half): how much of each coding/token
1237
+ * plan is left. The billing dashboard already exempts subscription providers
1238
+ * from per-token cost; this module surfaces the REMAINING quota so the user
1239
+ * sees plan headroom instead of a blank row.
1240
+ *
1241
+ * The panel shows only the plans the user actually configured: adapters with
1242
+ * a known quota API (Kimi, Z.ai, OpenCode Go) query the remaining amount;
1243
+ * other subscription providers the harness recognizes (volcengine / baidu /
1244
+ * qwen / xiaomi token plans, agent plans…) are identified and listed with a
1245
+ * "no quota API" marker rather than hidden. API keys come from the `llm-pi-ai`
1246
+ * settings namespace (`apiKeyEnv` refs) resolved through the credentials seam.
1247
+ */
1248
+ /** 空凭据:全部未配置时的初始值。 */
1249
+ const EMPTY_SUBSCRIPTION_KEYS = {
1250
+ kimiApiKey: "",
1251
+ zaiApiKey: "",
1252
+ opencodeApiKey: "",
1253
+ zaiRegion: "global"
1254
+ };
1255
+ /** 订阅类 provider 的显示名(未命中的回退为 id 本身)。 */
1256
+ const SUBSCRIPTION_DISPLAY_NAMES = {
1257
+ "kimi-coding": "Kimi For Coding",
1258
+ "zai-coding-cn": "Z.ai Coding Plan",
1259
+ "zai-coding": "Z.ai Coding Plan",
1260
+ "opencode": "OpenCode Plan",
1261
+ "opencode-go": "OpenCode Go",
1262
+ "qwen-token-plan": "通义 Token Plan",
1263
+ "qwen-token-plan-cn": "通义 Token Plan(国内)",
1264
+ "xiaomi-token-plan-ams": "小米 Token Plan(海外)",
1265
+ "xiaomi-token-plan-cn": "小米 Token Plan(国内)",
1266
+ "xiaomi-token-plan-sgp": "小米 Token Plan(新加坡)",
1267
+ "volcengine-token-plan": "火山引擎 Token Plan",
1268
+ "ark-token-plan": "火山方舟 Token Plan",
1269
+ "doubao-token-plan": "豆包 Token Plan",
1270
+ "ernie": "百度文心 Plan",
1271
+ "baidu": "百度文心 Plan",
1272
+ "wenxin": "百度文心 Plan",
1273
+ "minimax": "MiniMax Coding Plan"
1274
+ };
1275
+ /** 订阅类 provider id 判定:带 coding / agent-plan / token-plan 后缀,或已知订阅通道。 */
1276
+ const SUBSCRIPTION_ID_RE = /(?:^|-)(?:coding|agent[-_]?plan|token[-_]?plan)(?:$|-|_)|^(?:opencode|opencode-go|kimi-coding|zai-coding|minimax)/i;
1277
+ /** 是否是订阅类 provider id(如 kimi-coding、xiaomi-token-plan-cn)。 */
1278
+ function isSubscriptionProviderId(providerId) {
1279
+ if (SUBSCRIPTION_ID_RE.test(providerId)) return true;
1280
+ return SUBSCRIPTION_DISPLAY_NAMES[providerId] !== void 0;
1281
+ }
1282
+ /** 适配器注册表:provider id → 收集器(displayName 同步映射)。 */
1283
+ const SUBSCRIPTION_ADAPTERS = {
1284
+ "kimi-coding": { collect: collectKimi },
1285
+ "zai-coding-cn": { collect: collectZai },
1286
+ "opencode": { collect: collectOpenCodeGo },
1287
+ "opencode-go": { collect: collectOpenCodeGo }
1288
+ };
1289
+ /** 有额度适配器的 provider id 集合(识别用)。 */
1290
+ const ADAPTER_PROVIDER_IDS = new Set(Object.keys(SUBSCRIPTION_ADAPTERS));
1291
+ /**
1292
+ * 从 llm-pi-ai 设置里识别订阅套餐:带订阅类 id 且配置了 apiKeyEnv 的 provider。
1293
+ * @param providers - the `providers` map of the llm-pi-ai settings namespace.
1294
+ * @returns identified plans in configuration order.
1295
+ */
1296
+ function identifySubscriptionPlans(providers) {
1297
+ const out = [];
1298
+ for (const [id, config] of Object.entries(providers ?? {})) {
1299
+ if (typeof config?.apiKeyEnv !== "string" || config.apiKeyEnv === "") continue;
1300
+ if (!isSubscriptionProviderId(id)) continue;
1301
+ out.push({
1302
+ provider: id,
1303
+ displayName: SUBSCRIPTION_DISPLAY_NAMES[id] ?? id,
1304
+ adapter: ADAPTER_PROVIDER_IDS.has(id),
1305
+ ...id === "zai-coding-cn" ? { region: "bigmodel-cn" } : {}
1306
+ });
1307
+ }
1308
+ return out;
1309
+ }
1310
+ const DEFAULT_TIMEOUT_MS = 15e3;
1311
+ /** Number, or null when the value is not a finite number (nor numeric string). */
1312
+ function numberOrNull(value) {
1313
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1314
+ if (typeof value === "string" && value.trim() !== "") {
1315
+ const parsed = Number(value);
1316
+ if (Number.isFinite(parsed)) return parsed;
1317
+ }
1318
+ return null;
1319
+ }
1320
+ /** Clamp a percentage to 0–100. */
1321
+ function clampPercent(value) {
1322
+ return value === null ? null : Math.max(0, Math.min(100, value));
1323
+ }
1324
+ /** Round to one decimal. */
1325
+ function round1(value) {
1326
+ return Math.round(value * 10) / 10;
1327
+ }
1328
+ /** Number → ISO string (seconds treated as epoch seconds, ms as epoch ms). */
1329
+ function toIso(value) {
1330
+ if (value === null || value === void 0 || value === "") return null;
1331
+ if (typeof value === "number" && Number.isFinite(value)) {
1332
+ const date = new Date(value < 2e12 ? value * 1e3 : value);
1333
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
1334
+ }
1335
+ const date = new Date(String(value));
1336
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
1337
+ }
1338
+ /** Map a fetch error to a stable status. */
1339
+ function statusOf(error) {
1340
+ if (error instanceof Error) {
1341
+ if (error.name === "TimeoutError" || error.name === "AbortError") return "unavailable";
1342
+ const status = error.httpStatus;
1343
+ if (status === 401 || status === 403) return "unauthorized";
1344
+ if (status === 429) return "rate-limited";
1345
+ if (status === 404) return "unavailable";
1346
+ }
1347
+ return "unavailable";
1348
+ }
1349
+ /** One JSON fetch with a timeout, mapping HTTP failures to typed errors. */
1350
+ async function requestJson(url, init, timeoutMs) {
1351
+ const response = await fetch(url, {
1352
+ ...init,
1353
+ signal: AbortSignal.timeout(timeoutMs)
1354
+ });
1355
+ if (!response.ok) {
1356
+ const error = /* @__PURE__ */ new Error(`HTTP ${String(response.status)}`);
1357
+ error.httpStatus = response.status;
1358
+ throw error;
1359
+ }
1360
+ return await response.json();
1361
+ }
1362
+ /** Parse one Kimi limit window entry. */
1363
+ function kimiWindow(value, kind) {
1364
+ if (value === null || typeof value !== "object") return null;
1365
+ const record = value;
1366
+ const limit = numberOrNull(record.limit ?? record.total);
1367
+ const remaining = numberOrNull(record.remaining);
1368
+ if (limit === null || remaining === null || limit <= 0) return null;
1369
+ const usedPercent = round1(clampPercent((limit - remaining) / limit * 100) ?? 0);
1370
+ const resetsAt = toIso(record.resetTime ?? record.reset_time ?? record.resetsAt);
1371
+ return {
1372
+ kind,
1373
+ usedPercent,
1374
+ remainingPercent: round1(100 - usedPercent),
1375
+ remaining,
1376
+ ...resetsAt === null ? {} : { resetsAt }
1377
+ };
1378
+ }
1379
+ /** Parse a Kimi `/coding/v1/usages` body. */
1380
+ function parseKimi(body) {
1381
+ const record = body?.data ?? body ?? {};
1382
+ const session = (Array.isArray(record.limits) ? record.limits : []).map((entry) => kimiWindow(entry?.detail ?? entry, "session")).find((hit) => hit !== null) ?? null;
1383
+ const weekly = kimiWindow(record.usage, "weekly");
1384
+ const plan = typeof record.plan === "string" ? record.plan : typeof record.planName === "string" ? record.planName : void 0;
1385
+ return {
1386
+ ...plan !== void 0 && plan !== "" ? { plan } : {},
1387
+ windows: [session, weekly].filter((hit) => hit !== null)
1388
+ };
1389
+ }
1390
+ /** Collect the Kimi For Coding quota. */
1391
+ async function collectKimi(keys, config, timeoutMs) {
1392
+ const apiKey = keys.kimiApiKey.trim();
1393
+ const base = config.baseUrl ?? "https://api.kimi.com";
1394
+ if (apiKey === "") return {
1395
+ provider: config.provider,
1396
+ displayName: "Kimi For Coding",
1397
+ status: "not-configured",
1398
+ windows: []
1399
+ };
1400
+ try {
1401
+ const parsed = parseKimi(await requestJson(`${base}/coding/v1/usages`, { headers: {
1402
+ authorization: `Bearer ${apiKey}`,
1403
+ accept: "application/json"
1404
+ } }, timeoutMs));
1405
+ return {
1406
+ provider: config.provider,
1407
+ displayName: "Kimi For Coding",
1408
+ ...parsed.plan !== void 0 ? { plan: parsed.plan } : {},
1409
+ status: parsed.windows.length > 0 ? "ok" : "invalid-response",
1410
+ windows: parsed.windows
1411
+ };
1412
+ } catch (error) {
1413
+ return {
1414
+ provider: config.provider,
1415
+ displayName: "Kimi For Coding",
1416
+ status: statusOf(error),
1417
+ windows: []
1418
+ };
1419
+ }
1420
+ }
1421
+ /** Window length in minutes for a Z.ai limit row; null when unknown. */
1422
+ function zaiWindowMinutes(limit) {
1423
+ const unit = numberOrNull(limit.unit);
1424
+ const number = numberOrNull(limit.number);
1425
+ if (unit === null || number === null || number <= 0) return null;
1426
+ if (unit === 5) return number;
1427
+ if (unit === 3) return number * 60;
1428
+ if (unit === 1) return number * 24 * 60;
1429
+ if (unit === 6) return number * 7 * 24 * 60;
1430
+ return null;
1431
+ }
1432
+ /** Used percent for a Z.ai limit row. */
1433
+ function zaiUsedPercent(limit) {
1434
+ const total = numberOrNull(limit.usage);
1435
+ const remaining = numberOrNull(limit.remaining);
1436
+ const current = numberOrNull(limit.currentValue ?? limit.current_value);
1437
+ if (total !== null && total > 0) {
1438
+ const used = remaining === null ? current : current === null ? total - remaining : Math.max(total - remaining, current);
1439
+ if (used !== null) return clampPercent(Math.max(0, Math.min(total, used)) / total * 100);
1440
+ }
1441
+ return clampPercent(numberOrNull(limit.percentage ?? limit.usedPercent ?? limit.used_percent));
1442
+ }
1443
+ /** One Z.ai quota window row. */
1444
+ function zaiWindow(limit, kind, fallbackReset = null) {
1445
+ const usedPercent = zaiUsedPercent(limit);
1446
+ if (usedPercent === null) return null;
1447
+ const resetsAt = toIso(limit.nextResetTime ?? limit.next_reset_time) ?? fallbackReset;
1448
+ return {
1449
+ kind,
1450
+ usedPercent: round1(usedPercent),
1451
+ remainingPercent: round1(100 - usedPercent),
1452
+ ...resetsAt === null ? {} : { resetsAt }
1453
+ };
1454
+ }
1455
+ /** Parse Z.ai quota + subscription bodies into windows. */
1456
+ function parseZai(quotaBody, subscriptionBody) {
1457
+ const quota = quotaBody ?? {};
1458
+ const limits = Array.isArray(quota.data?.limits) ? quota.data.limits : [];
1459
+ const tokenLimits = limits.filter((entry) => {
1460
+ const record = entry;
1461
+ const type = String(record.type ?? record.limit_type ?? "").toUpperCase();
1462
+ return (type === "TOKENS_LIMIT" || type === "CREDIT_LIMIT") && zaiUsedPercent(record) !== null;
1463
+ }).sort((a, b) => (zaiWindowMinutes(a) ?? Number.MAX_SAFE_INTEGER) - (zaiWindowMinutes(b) ?? Number.MAX_SAFE_INTEGER));
1464
+ const timeLimit = limits.find((entry) => {
1465
+ const record = entry;
1466
+ return String(record.type ?? record.limit_type ?? "").toUpperCase() === "TIME_LIMIT" && zaiUsedPercent(record) !== null;
1467
+ });
1468
+ const first = tokenLimits[0];
1469
+ const session = tokenLimits.length >= 2 ? first : first !== void 0 && zaiWindowMinutes(first) !== null && (zaiWindowMinutes(first) ?? 0) <= 360 ? first : void 0;
1470
+ const weekly = tokenLimits.length >= 2 ? tokenLimits[tokenLimits.length - 1] : session === void 0 ? first : void 0;
1471
+ const subscriptionRow = subscriptionBody?.data;
1472
+ const renewAt = toIso(Array.isArray(subscriptionRow) ? subscriptionRow[0]?.next_renew_time ?? subscriptionRow[0]?.nextRenewTime : void 0);
1473
+ const row = Array.isArray(subscriptionRow) ? subscriptionRow[0] : void 0;
1474
+ let plan = "GLM Coding Plan";
1475
+ for (const source of [row, quota.data]) {
1476
+ if (source === null || typeof source !== "object") continue;
1477
+ const record = source;
1478
+ for (const key of [
1479
+ "product_name",
1480
+ "productName",
1481
+ "plan_name",
1482
+ "planName",
1483
+ "package_name",
1484
+ "packageName",
1485
+ "level"
1486
+ ]) {
1487
+ const value = record[key];
1488
+ if (typeof value === "string" && value.trim() !== "") {
1489
+ plan = value.trim();
1490
+ break;
1491
+ }
1492
+ }
1493
+ if (plan !== "GLM Coding Plan") break;
1494
+ }
1495
+ return {
1496
+ plan,
1497
+ windows: [
1498
+ session === void 0 ? null : zaiWindow(session, "session"),
1499
+ weekly === void 0 ? null : zaiWindow(weekly, "weekly"),
1500
+ timeLimit === void 0 ? null : zaiWindow(timeLimit, "billing", renewAt)
1501
+ ].filter((hit) => hit !== null)
1502
+ };
1503
+ }
1504
+ /** Collect the Z.ai Coding Plan quota. */
1505
+ async function collectZai(keys, config, timeoutMs) {
1506
+ const apiKey = keys.zaiApiKey.trim();
1507
+ const host = (config.region ?? keys.zaiRegion ?? "global") === "bigmodel-cn" ? "https://open.bigmodel.cn" : "https://api.z.ai";
1508
+ if (apiKey === "") return {
1509
+ provider: config.provider,
1510
+ displayName: "Z.ai Coding Plan",
1511
+ status: "not-configured",
1512
+ windows: []
1513
+ };
1514
+ try {
1515
+ const init = { headers: {
1516
+ authorization: apiKey,
1517
+ accept: "application/json"
1518
+ } };
1519
+ const quota = await requestJson(`${host}/api/monitor/usage/quota/limit`, init, timeoutMs);
1520
+ let subscription = null;
1521
+ try {
1522
+ subscription = await requestJson(`${host}/api/biz/subscription/list`, init, timeoutMs);
1523
+ } catch {}
1524
+ const parsed = parseZai(quota, subscription);
1525
+ return {
1526
+ provider: config.provider,
1527
+ displayName: "Z.ai Coding Plan",
1528
+ plan: parsed.plan,
1529
+ status: parsed.windows.length > 0 ? "ok" : "invalid-response",
1530
+ windows: parsed.windows
1531
+ };
1532
+ } catch (error) {
1533
+ return {
1534
+ provider: config.provider,
1535
+ displayName: "Z.ai Coding Plan",
1536
+ status: statusOf(error),
1537
+ windows: []
1538
+ };
1539
+ }
1540
+ }
1541
+ /** Parse one OpenCode Go window object. */
1542
+ function goWindow(value, kind) {
1543
+ if (value === null || typeof value !== "object") return null;
1544
+ const record = value;
1545
+ const percentSource = record.usagePercent ?? record.usedPercent ?? record.percentUsed ?? record.percentage ?? record.percent;
1546
+ let usedPercent = clampPercent(numberOrNull(percentSource));
1547
+ if (usedPercent === null) {
1548
+ const used = numberOrNull(record.used ?? record.consumed);
1549
+ const limit = numberOrNull(record.limit ?? record.total ?? record.quota);
1550
+ if (used !== null && limit !== null && limit > 0) usedPercent = clampPercent(used / limit * 100);
1551
+ }
1552
+ if (usedPercent === null) return null;
1553
+ if (usedPercent <= 1 && usedPercent >= 0 && record.percent === void 0 && percentSource !== void 0) usedPercent *= 100;
1554
+ const resetSeconds = numberOrNull(record.resetInSec ?? record.resetInSeconds ?? record.resetSeconds);
1555
+ const resetsAt = resetSeconds === null ? toIso(record.resetAt ?? record.resetsAt ?? record.nextReset) : new Date(Date.now() + Math.max(0, resetSeconds) * 1e3).toISOString();
1556
+ return {
1557
+ kind,
1558
+ usedPercent: round1(clampPercent(usedPercent) ?? 0),
1559
+ remainingPercent: round1(100 - (clampPercent(usedPercent) ?? 0)),
1560
+ ...resetsAt === null ? {} : { resetsAt }
1561
+ };
1562
+ }
1563
+ /** Parse the OpenCode Go Bearer endpoint body. */
1564
+ function parseOpenCodeGoApi(body) {
1565
+ const usage = body?.usage ?? body;
1566
+ if (usage === null || typeof usage !== "object") return [];
1567
+ const record = usage;
1568
+ return [
1569
+ goWindow(record.rolling, "session"),
1570
+ goWindow(record.weekly, "weekly"),
1571
+ goWindow(record.monthly, "monthly")
1572
+ ].filter((hit) => hit !== null);
1573
+ }
1574
+ /** Collect the OpenCode Go quota. */
1575
+ async function collectOpenCodeGo(keys, config, timeoutMs) {
1576
+ const apiKey = keys.opencodeApiKey.trim();
1577
+ const base = config.baseUrl ?? "https://opencode.ai";
1578
+ if (apiKey === "") return {
1579
+ provider: config.provider,
1580
+ displayName: "OpenCode Go",
1581
+ status: "not-configured",
1582
+ windows: []
1583
+ };
1584
+ try {
1585
+ const windows = parseOpenCodeGoApi(await requestJson(`${base}/zen/go/v1/usage`, { headers: {
1586
+ authorization: `Bearer ${apiKey}`,
1587
+ accept: "application/json"
1588
+ } }, timeoutMs));
1589
+ return {
1590
+ provider: config.provider,
1591
+ displayName: "OpenCode Go",
1592
+ status: windows.length > 0 ? "ok" : "invalid-response",
1593
+ windows
1594
+ };
1595
+ } catch (error) {
1596
+ return {
1597
+ provider: config.provider,
1598
+ displayName: "OpenCode Go",
1599
+ status: statusOf(error),
1600
+ windows: []
1601
+ };
1602
+ }
1603
+ }
1604
+ /**
1605
+ * Collect quota for the given plans concurrently (adapter-backed plans only;
1606
+ * identified plans without an adapter are surfaced by the caller as "no
1607
+ * quota API" rows).
1608
+ * @param keys - the API keys from the llm-pi-ai settings namespace.
1609
+ * @param plans - adapter-backed plans to poll; empty by default.
1610
+ * @param timeoutMs - per-request timeout; defaults to 15s.
1611
+ * @returns the quotas in plan order (unknown providers degrade to `unavailable`).
1612
+ */
1613
+ async function collectSubscriptions(keys, plans = [], timeoutMs = DEFAULT_TIMEOUT_MS) {
1614
+ return await Promise.all(plans.map((plan) => {
1615
+ const adapter = SUBSCRIPTION_ADAPTERS[plan.provider];
1616
+ if (adapter === void 0) return Promise.resolve({
1617
+ provider: plan.provider,
1618
+ displayName: plan.provider,
1619
+ status: "unavailable",
1620
+ windows: []
1621
+ });
1622
+ return adapter.collect(keys, plan, timeoutMs);
1623
+ }));
1624
+ }
1625
+ //#endregion
1033
1626
  //#region lib/types/index.js
1034
1627
  /**
1035
1628
  * Usage billing surface plugin, node half.
@@ -1044,15 +1637,85 @@ async function fetchLivePricing() {
1044
1637
  */
1045
1638
  /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
1046
1639
  const PRICING_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
1640
+ /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
1641
+ const SUBSCRIPTION_CACHE_MS = 300 * 1e3;
1047
1642
  /** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
1048
1643
  const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
1049
- /** Required services: the web server and the persisted session log store. */
1644
+ /** Required services: the web server, the persisted session log store, and user settings. */
1050
1645
  const inject = [
1051
1646
  "webServer",
1052
1647
  "sessionPersistence",
1053
- "credentials"
1648
+ "credentials",
1649
+ "settings"
1054
1650
  ];
1055
1651
  /**
1652
+ * 订阅 provider id(llm-pi-ai 设置键)→ billing 适配器 key 的映射。
1653
+ * 复用 dsh 既有的 llm-pi-ai provider 配置(apiKeyEnv 引用),不引入新配置面。
1654
+ */
1655
+ const SUBSCRIPTION_KEY_SOURCES = [
1656
+ {
1657
+ provider: "kimi-coding",
1658
+ key: "kimiApiKey"
1659
+ },
1660
+ {
1661
+ provider: "zai-coding-cn",
1662
+ key: "zaiApiKey"
1663
+ },
1664
+ {
1665
+ provider: "opencode",
1666
+ key: "opencodeApiKey"
1667
+ },
1668
+ {
1669
+ provider: "opencode-go",
1670
+ key: "opencodeApiKey"
1671
+ }
1672
+ ];
1673
+ /**
1674
+ * 读取 llm-pi-ai 设置的 `providers` 字典(`<route> → { apiKeyEnv? }`)。
1675
+ * 余额查询复用同一份来源:部署为某个 provider 配一次 key,多个 surface 共享。
1676
+ * @param settings - the settings service (reads the llm-pi-ai namespace).
1677
+ * @returns the providers dict; empty when the namespace is unreadable.
1678
+ */
1679
+ async function readPiAiProviders(settings) {
1680
+ try {
1681
+ return (settings.describe({ redactSecrets: true }).find((descriptor) => descriptor.ns === "llm-pi-ai")?.value)?.providers ?? {};
1682
+ } catch {
1683
+ return {};
1684
+ }
1685
+ }
1686
+ /**
1687
+ * 解析订阅适配器需要的 API Key:从 llm-pi-ai 设置的 `providers.<id>.apiKeyEnv`
1688
+ * 读引用(如 kimi-coding → KIMI_CODING_API_KEY),再经凭据 seam 解析成实际值。
1689
+ * 同时识别出用户配置了 key 的订阅套餐(供面板只显示已识别的)。
1690
+ * @param settings - the settings service (reads the llm-pi-ai namespace).
1691
+ * @param credentials - the credentials service (resolves the env refs).
1692
+ */
1693
+ async function resolveSubscriptionKeys(settings, credentials) {
1694
+ const keys = { ...EMPTY_SUBSCRIPTION_KEYS };
1695
+ let providers;
1696
+ try {
1697
+ providers = (settings.describe({ redactSecrets: true }).find((descriptor) => descriptor.ns === "llm-pi-ai")?.value)?.providers;
1698
+ } catch {
1699
+ return {
1700
+ keys,
1701
+ identified: []
1702
+ };
1703
+ }
1704
+ for (const { provider, key } of SUBSCRIPTION_KEY_SOURCES) {
1705
+ const env = providers?.[provider]?.apiKeyEnv;
1706
+ if (typeof env !== "string" || env === "") continue;
1707
+ try {
1708
+ const resolved = await credentials.resolve(credentialRef(env));
1709
+ if (resolved?.value !== void 0 && resolved.value !== "") keys[key] = resolved.value;
1710
+ } catch {}
1711
+ }
1712
+ if (providers?.["zai-coding-cn"]?.apiKeyEnv !== void 0 && keys.zaiApiKey !== "") keys.zaiRegion = "bigmodel-cn";
1713
+ return {
1714
+ keys,
1715
+ identified: identifySubscriptionPlans(providers)
1716
+ };
1717
+ }
1718
+ /**
1056
1719
  * Host plugin body: serve real aggregated usage to the browser dashboard.
1057
1720
  * @param ctx - host context carrying webServer and sessionPersistence.
1058
1721
  * @param config - optional statsPath override.
@@ -1092,10 +1755,42 @@ function apply(ctx, config = {}) {
1092
1755
  path: "/api/billing/balance",
1093
1756
  handler: async (_req, res) => {
1094
1757
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1095
- const balances = await queryBalances(ctx, config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV);
1758
+ const providers = { ...await readPiAiProviders(ctx.settings) };
1759
+ if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
1760
+ const balances = await queryBalances(ctx, providers);
1096
1761
  res.end(JSON.stringify({ balances }));
1097
1762
  }
1098
1763
  }), "usage-billing: balance route");
1764
+ let quotaCache = {
1765
+ at: 0,
1766
+ quotas: []
1767
+ };
1768
+ const refreshQuotas = async () => {
1769
+ const { keys, identified } = await resolveSubscriptionKeys(ctx.settings, ctx.credentials);
1770
+ const rows = [...await collectSubscriptions(keys, identified.filter((item) => item.adapter).map((item) => ({
1771
+ provider: item.provider,
1772
+ ...item.region === void 0 ? {} : { region: item.region }
1773
+ })))];
1774
+ for (const item of identified) if (!item.adapter) rows.push({
1775
+ provider: item.provider,
1776
+ displayName: item.displayName,
1777
+ status: "ok",
1778
+ windows: []
1779
+ });
1780
+ quotaCache = {
1781
+ at: Date.now(),
1782
+ quotas: rows
1783
+ };
1784
+ };
1785
+ ctx.effect(() => ctx.webServer.register({
1786
+ kind: "exact",
1787
+ path: "/api/billing/subscriptions",
1788
+ handler: async (_req, res) => {
1789
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1790
+ if (Date.now() - quotaCache.at >= SUBSCRIPTION_CACHE_MS) await refreshQuotas();
1791
+ res.end(JSON.stringify({ quotas: quotaCache.quotas }));
1792
+ }
1793
+ }), "usage-billing: subscriptions route");
1099
1794
  ctx.effect(() => ctx.webServer.register({
1100
1795
  kind: "exact",
1101
1796
  path: "/api/billing/usage-stats",
@@ -1126,4 +1821,4 @@ function apply(ctx, config = {}) {
1126
1821
  }), "usage-billing: usage-stats route");
1127
1822
  }
1128
1823
  //#endregion
1129
- export { apply, inject };
1824
+ export { apply, inject, resolveSubscriptionKeys };