@kenz1117/dsh-ui-usage-billing 0.3.0 → 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
@@ -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;
@@ -1030,6 +1169,398 @@ async function fetchLivePricing() {
1030
1169
  };
1031
1170
  }
1032
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
1033
1564
  //#region lib/types/index.js
1034
1565
  /**
1035
1566
  * Usage billing surface plugin, node half.
@@ -1044,15 +1575,72 @@ async function fetchLivePricing() {
1044
1575
  */
1045
1576
  /** 实时定价的后台刷新间隔(毫秒):汇率/模型价低频变化,6 小时一次足够。 */
1046
1577
  const PRICING_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
1578
+ /** 订阅套餐额度缓存时长(毫秒):上游配额 API 低频变化,5 分钟足够。 */
1579
+ const SUBSCRIPTION_CACHE_MS = 300 * 1e3;
1047
1580
  /** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
1048
1581
  const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
1049
- /** 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. */
1050
1583
  const inject = [
1051
1584
  "webServer",
1052
1585
  "sessionPersistence",
1053
- "credentials"
1586
+ "credentials",
1587
+ "settings"
1588
+ ];
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
+ }
1054
1610
  ];
1055
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
+ /**
1056
1644
  * Host plugin body: serve real aggregated usage to the browser dashboard.
1057
1645
  * @param ctx - host context carrying webServer and sessionPersistence.
1058
1646
  * @param config - optional statsPath override.
@@ -1096,6 +1684,36 @@ function apply(ctx, config = {}) {
1096
1684
  res.end(JSON.stringify({ balances }));
1097
1685
  }
1098
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");
1099
1717
  ctx.effect(() => ctx.webServer.register({
1100
1718
  kind: "exact",
1101
1719
  path: "/api/billing/usage-stats",
@@ -1126,4 +1744,4 @@ function apply(ctx, config = {}) {
1126
1744
  }), "usage-billing: usage-stats route");
1127
1745
  }
1128
1746
  //#endregion
1129
- export { apply, inject };
1747
+ export { apply, inject, resolveSubscriptionKeys };