@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/README.md +44 -35
- package/lib/client.js +1080 -229
- package/lib/index.js +651 -33
- package/lib/types/aggregate.d.ts +50 -9
- package/lib/types/client/pricing.d.ts +41 -2
- package/lib/types/index.d.ts +17 -1
- package/lib/types/pricing-shared.d.ts +39 -0
- package/lib/types/subscriptions.d.ts +59 -0
- package/package.json +4 -1
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
|
-
|
|
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 +=
|
|
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:
|
|
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:
|
|
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
|
|
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 };
|