@kenz1117/dsh-ui-usage-billing 0.2.6 → 0.3.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 +32 -4
- package/lib/client.js +764 -257
- package/lib/index.js +194 -63
- package/lib/types/aggregate.d.ts +73 -6
- package/lib/types/client/TrendChart.d.ts +5 -4
- package/lib/types/client/UsageBilling.d.ts +30 -4
- package/lib/types/client/apply.d.ts +31 -0
- package/lib/types/client/billing-service.d.ts +40 -0
- package/lib/types/client/budget-store.d.ts +32 -0
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/pricing.d.ts +0 -10
- package/lib/types/index.d.ts +5 -0
- package/package.json +2 -1
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
@@ -9,18 +9,6 @@ function currentRate() {
|
|
|
9
9
|
/** Default share of traffic assumed to fall in the peak band (0..1). */
|
|
10
10
|
const DEFAULT_PEAK_SHARE = .5;
|
|
11
11
|
/**
|
|
12
|
-
* Model keys served through a subscription plan (e.g. a coding plan or topic
|
|
13
|
-
* plan) instead of metered per-token API billing. Usage through these routes
|
|
14
|
-
* costs no tokens: the estimator treats them as ¥0 and the billing table
|
|
15
|
-
* labels them 订阅包含. Add any model key your deployment serves through a
|
|
16
|
-
* plan here; leave empty when every route is pay-as-you-go.
|
|
17
|
-
*/
|
|
18
|
-
const SUBSCRIPTION_PLAN_KEYS = [];
|
|
19
|
-
/** Whether one stats model key is billed through a subscription plan. */
|
|
20
|
-
function isSubscriptionPlan(key) {
|
|
21
|
-
return SUBSCRIPTION_PLAN_KEYS.includes(key);
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
12
|
* Built-in catalog of current mainstream models as of 2026-08-16, priced from
|
|
25
13
|
* each provider's official price page. Domestic providers are OpenAI-API
|
|
26
14
|
* compatible and publish RMB prices directly; overseas providers publish USD
|
|
@@ -240,6 +228,18 @@ const MODEL_CATALOG = [
|
|
|
240
228
|
output: 100
|
|
241
229
|
}
|
|
242
230
|
},
|
|
231
|
+
{
|
|
232
|
+
key: "mimo-v2.5",
|
|
233
|
+
name: "MiMo V2.5",
|
|
234
|
+
provider: "小米",
|
|
235
|
+
colorVar: "dsw-static-green-400",
|
|
236
|
+
price: {
|
|
237
|
+
currency: "CNY",
|
|
238
|
+
input: 4,
|
|
239
|
+
cacheHit: .4,
|
|
240
|
+
output: 12
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
243
|
{
|
|
244
244
|
key: "minimax",
|
|
245
245
|
name: "MiniMax-M3",
|
|
@@ -520,7 +520,6 @@ function priceBandCost(band, buckets, currency) {
|
|
|
520
520
|
* @returns the estimated cost in CNY.
|
|
521
521
|
*/
|
|
522
522
|
function computeCost(entry, buckets, peakShare = DEFAULT_PEAK_SHARE) {
|
|
523
|
-
if (isSubscriptionPlan(entry.key)) return 0;
|
|
524
523
|
const peak = priceBandCost(entry.price, buckets, entry.price.currency);
|
|
525
524
|
const off = entry.price.offPeak === void 0 ? peak : priceBandCost(entry.price.offPeak, buckets, entry.price.currency);
|
|
526
525
|
return peak * peakShare + off * (1 - peakShare);
|
|
@@ -552,14 +551,28 @@ const MODEL_KEY_ALIASES = {
|
|
|
552
551
|
"qwen-max": "qwen-max",
|
|
553
552
|
"hunyuan-t1": "hunyuan-t1",
|
|
554
553
|
"step-3.7-flash": "step",
|
|
555
|
-
"seed-2.0-mini": "doubao-mini"
|
|
554
|
+
"seed-2.0-mini": "doubao-mini",
|
|
555
|
+
"k3": "kimi-k3",
|
|
556
|
+
"kimi-k3": "kimi-k3"
|
|
556
557
|
};
|
|
557
558
|
/**
|
|
558
|
-
* 走订阅套餐(coding / token /
|
|
559
|
-
*
|
|
560
|
-
*
|
|
559
|
+
* 走订阅套餐(coding / token plan / opencode 订阅)的 provider id:这些通道的
|
|
560
|
+
* 调用按套餐计费,不再按 token 计费,因此即使模型 id 与计费表撞名也一律豁免。
|
|
561
|
+
* 与 pi-ai 内置提供方对齐(含各地区变体:qwen/xiaomi 的 token-plan、opencode 与
|
|
562
|
+
* opencode-go、zai-coding-cn);部署可在 plugin config 的 `subscriptionProviders`
|
|
563
|
+
* 中覆盖。
|
|
561
564
|
*/
|
|
562
|
-
const DEFAULT_SUBSCRIPTION_PROVIDERS = [
|
|
565
|
+
const DEFAULT_SUBSCRIPTION_PROVIDERS = [
|
|
566
|
+
"kimi-coding",
|
|
567
|
+
"zai-coding-cn",
|
|
568
|
+
"opencode",
|
|
569
|
+
"opencode-go",
|
|
570
|
+
"qwen-token-plan",
|
|
571
|
+
"qwen-token-plan-cn",
|
|
572
|
+
"xiaomi-token-plan-ams",
|
|
573
|
+
"xiaomi-token-plan-cn",
|
|
574
|
+
"xiaomi-token-plan-sgp"
|
|
575
|
+
];
|
|
563
576
|
/** Zeroed usage accumulator. */
|
|
564
577
|
function emptyUsage() {
|
|
565
578
|
return {
|
|
@@ -588,12 +601,12 @@ function foldUsage(acc, usage, key, subscription) {
|
|
|
588
601
|
acc.output += usage.outputTokens;
|
|
589
602
|
acc.cacheHit += cacheHit;
|
|
590
603
|
acc.cacheMiss += cacheMiss;
|
|
591
|
-
|
|
592
|
-
input:
|
|
593
|
-
cacheHit
|
|
594
|
-
cacheMiss
|
|
595
|
-
output:
|
|
596
|
-
})
|
|
604
|
+
if (!subscription && MODEL_CATALOG.some((entry) => entry.key === key)) acc.cost += computeCost(modelOf(key), {
|
|
605
|
+
input: cacheHit + cacheMiss,
|
|
606
|
+
cacheHit,
|
|
607
|
+
cacheMiss,
|
|
608
|
+
output: usage.outputTokens
|
|
609
|
+
});
|
|
597
610
|
}
|
|
598
611
|
/** Local-time date stamp (the host runs in the user's timezone). */
|
|
599
612
|
function dayStamp(time) {
|
|
@@ -619,48 +632,155 @@ function modelDayCell(map, day, modelKey) {
|
|
|
619
632
|
return usageCell(models, modelKey);
|
|
620
633
|
}
|
|
621
634
|
/**
|
|
622
|
-
*
|
|
635
|
+
* Fold one session's events into a {@link SessionFold}. 每个 LLM 调用归属到
|
|
636
|
+
* 其前置 request/header 记录的模型;同时提取最新会话标题与最后活跃时间。
|
|
637
|
+
* @param events - the session's persisted events in log order.
|
|
638
|
+
* @param subscriptionProviders - provider ids billed through subscription plans.
|
|
639
|
+
* @returns the per-session fold (cached by the incremental aggregator).
|
|
640
|
+
*/
|
|
641
|
+
function foldSession(events, subscriptionProviders) {
|
|
642
|
+
const fold = {
|
|
643
|
+
total: emptyUsage(),
|
|
644
|
+
byModel: /* @__PURE__ */ new Map(),
|
|
645
|
+
byDay: /* @__PURE__ */ new Map(),
|
|
646
|
+
byDayModels: /* @__PURE__ */ new Map(),
|
|
647
|
+
planCalls: /* @__PURE__ */ new Map(),
|
|
648
|
+
lastActive: 0
|
|
649
|
+
};
|
|
650
|
+
let key = "other";
|
|
651
|
+
let subscription = false;
|
|
652
|
+
for (const event of events) {
|
|
653
|
+
fold.lastActive = Math.max(fold.lastActive, event.time);
|
|
654
|
+
if (event.type === "session/title") {
|
|
655
|
+
const title = event.data.title;
|
|
656
|
+
if (typeof title === "string" && title.length > 0) fold.title = title;
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (event.type === "request/header") {
|
|
660
|
+
const { model, provider } = event.data.header.config;
|
|
661
|
+
key = MODEL_KEY_ALIASES[model] ?? model;
|
|
662
|
+
subscription = subscriptionProviders.has(provider);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (event.type !== "assistant/message") continue;
|
|
666
|
+
const usage = event.data.usage;
|
|
667
|
+
if (usage === void 0) continue;
|
|
668
|
+
const modelKey = key;
|
|
669
|
+
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);
|
|
674
|
+
if (subscription) fold.planCalls.set(modelKey, (fold.planCalls.get(modelKey) ?? 0) + 1);
|
|
675
|
+
}
|
|
676
|
+
return fold;
|
|
677
|
+
}
|
|
678
|
+
/** Accumulate one ModelUsage into another (merge step of the incremental aggregator). */
|
|
679
|
+
function mergeUsageInto(acc, cell) {
|
|
680
|
+
acc.calls += cell.calls;
|
|
681
|
+
acc.input += cell.input;
|
|
682
|
+
acc.output += cell.output;
|
|
683
|
+
acc.cacheHit += cell.cacheHit;
|
|
684
|
+
acc.cacheMiss += cell.cacheMiss;
|
|
685
|
+
acc.cost += cell.cost;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Create the incremental usage aggregator.
|
|
623
689
|
* @param persistence - the session persistence service.
|
|
624
690
|
* @param options - aggregation tuning (e.g. subscription-plan providers).
|
|
625
|
-
* @returns the
|
|
691
|
+
* @returns the aggregator holding the per-session fold cache.
|
|
626
692
|
*/
|
|
627
|
-
|
|
693
|
+
function createUsageAggregator(persistence, options = {}) {
|
|
628
694
|
const subscriptionProviders = new Set(options.subscriptionProviders ?? DEFAULT_SUBSCRIPTION_PROVIDERS);
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
695
|
+
const cache = /* @__PURE__ */ new Map();
|
|
696
|
+
let lastDoc;
|
|
697
|
+
let lastAt = 0;
|
|
698
|
+
/** 失效键:日志文件的 mtime+size;拿不到(后端无 locate / 文件丢失)时每次重折。 */
|
|
699
|
+
const stampOf = async (meta) => {
|
|
700
|
+
const location = persistence.locate?.(meta);
|
|
701
|
+
if (location === void 0) return null;
|
|
702
|
+
try {
|
|
703
|
+
const info = await stat(location.path);
|
|
704
|
+
return `${String(info.mtimeMs)}:${String(info.size)}`;
|
|
705
|
+
} catch {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
return { async aggregate() {
|
|
710
|
+
const now = Date.now();
|
|
711
|
+
if (lastDoc !== void 0 && now - lastAt < 5e3) return lastDoc;
|
|
712
|
+
const metas = await persistence.list();
|
|
713
|
+
const seen = /* @__PURE__ */ new Set();
|
|
714
|
+
const folds = [];
|
|
715
|
+
for (const meta of metas) {
|
|
716
|
+
const id = String(meta.id);
|
|
717
|
+
seen.add(id);
|
|
718
|
+
const stamp = await stampOf(meta);
|
|
719
|
+
const hit = cache.get(id);
|
|
720
|
+
if (hit !== void 0 && stamp !== null && hit.stamp === stamp) {
|
|
721
|
+
folds.push({
|
|
722
|
+
meta,
|
|
723
|
+
fold: hit.fold
|
|
724
|
+
});
|
|
642
725
|
continue;
|
|
643
726
|
}
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
727
|
+
const { events } = await persistence.readFrom(meta.id, 0);
|
|
728
|
+
const fold = foldSession(events, subscriptionProviders);
|
|
729
|
+
cache.set(id, {
|
|
730
|
+
stamp,
|
|
731
|
+
fold
|
|
732
|
+
});
|
|
733
|
+
folds.push({
|
|
734
|
+
meta,
|
|
735
|
+
fold
|
|
736
|
+
});
|
|
651
737
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
738
|
+
for (const key of [...cache.keys()]) if (!seen.has(key)) cache.delete(key);
|
|
739
|
+
const total = emptyUsage();
|
|
740
|
+
const byModel = /* @__PURE__ */ new Map();
|
|
741
|
+
const byDay = /* @__PURE__ */ new Map();
|
|
742
|
+
const byDayModels = /* @__PURE__ */ new Map();
|
|
743
|
+
const planCalls = /* @__PURE__ */ new Map();
|
|
744
|
+
const sessionRows = [];
|
|
745
|
+
for (const { meta, fold } of folds) {
|
|
746
|
+
mergeUsageInto(total, fold.total);
|
|
747
|
+
for (const [modelKey, cell] of fold.byModel) mergeUsageInto(usageCell(byModel, modelKey), cell);
|
|
748
|
+
for (const [day, cell] of fold.byDay) mergeUsageInto(usageCell(byDay, day), cell);
|
|
749
|
+
for (const [day, models] of fold.byDayModels) for (const [modelKey, cell] of models) mergeUsageInto(modelDayCell(byDayModels, day, modelKey), cell);
|
|
750
|
+
for (const [modelKey, count] of fold.planCalls) planCalls.set(modelKey, (planCalls.get(modelKey) ?? 0) + count);
|
|
751
|
+
if (fold.total.calls > 0) sessionRows.push({
|
|
752
|
+
id: String(meta.id),
|
|
753
|
+
...fold.title !== void 0 ? { title: fold.title } : {},
|
|
754
|
+
...meta.cwd !== void 0 ? { cwd: meta.cwd } : {},
|
|
755
|
+
calls: fold.total.calls,
|
|
756
|
+
cost: fold.total.cost,
|
|
757
|
+
lastActive: fold.lastActive
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
sessionRows.sort((a, b) => b.cost - a.cost || b.lastActive - a.lastActive);
|
|
761
|
+
const toRecord = (map) => {
|
|
762
|
+
const record = {};
|
|
763
|
+
for (const [key, cell] of map) if (planCalls.get(key) === cell.calls && cell.calls > 0) record[key] = {
|
|
764
|
+
...cell,
|
|
765
|
+
plan: true
|
|
766
|
+
};
|
|
767
|
+
else record[key] = cell;
|
|
768
|
+
return record;
|
|
769
|
+
};
|
|
770
|
+
const toModelDayRecord = (map) => Object.fromEntries([...map].map(([day, models]) => [day, Object.fromEntries(models)]));
|
|
771
|
+
lastDoc = {
|
|
772
|
+
version: 2,
|
|
773
|
+
updatedAt: now,
|
|
774
|
+
source: "session-logs",
|
|
775
|
+
total,
|
|
776
|
+
byModel: toRecord(byModel),
|
|
777
|
+
byDay: toRecord(byDay),
|
|
778
|
+
byDayModels: toModelDayRecord(byDayModels),
|
|
779
|
+
bySession: sessionRows.slice(0, 100)
|
|
780
|
+
};
|
|
781
|
+
lastAt = now;
|
|
782
|
+
return lastDoc;
|
|
783
|
+
} };
|
|
664
784
|
}
|
|
665
785
|
//#endregion
|
|
666
786
|
//#region lib/types/balance.js
|
|
@@ -938,6 +1058,7 @@ const inject = [
|
|
|
938
1058
|
* @param config - optional statsPath override.
|
|
939
1059
|
*/
|
|
940
1060
|
function apply(ctx, config = {}) {
|
|
1061
|
+
const aggregator = createUsageAggregator(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } });
|
|
941
1062
|
const cwd = process.cwd();
|
|
942
1063
|
const candidates = [
|
|
943
1064
|
config.statsPath,
|
|
@@ -981,13 +1102,23 @@ function apply(ctx, config = {}) {
|
|
|
981
1102
|
handler: async (_req, res) => {
|
|
982
1103
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
983
1104
|
try {
|
|
984
|
-
|
|
1105
|
+
const stats = await aggregator.aggregate();
|
|
1106
|
+
const injected = {
|
|
1107
|
+
...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
|
|
1108
|
+
...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
|
|
1109
|
+
};
|
|
1110
|
+
res.end(JSON.stringify(Object.keys(injected).length === 0 ? stats : {
|
|
1111
|
+
...stats,
|
|
1112
|
+
...injected
|
|
1113
|
+
}));
|
|
985
1114
|
return;
|
|
986
1115
|
} catch {}
|
|
987
1116
|
for (const candidate of candidates) try {
|
|
988
1117
|
const text = await readFile(candidate, "utf8");
|
|
989
|
-
JSON.parse(text);
|
|
990
|
-
|
|
1118
|
+
const doc = JSON.parse(text);
|
|
1119
|
+
if (config.monthlyBudget !== void 0) doc["budget"] = config.monthlyBudget;
|
|
1120
|
+
if (config.lowBalanceThreshold !== void 0) doc["lowBalanceThreshold"] = config.lowBalanceThreshold;
|
|
1121
|
+
res.end(JSON.stringify(doc));
|
|
991
1122
|
return;
|
|
992
1123
|
} catch {}
|
|
993
1124
|
res.end(JSON.stringify({ error: "usage stats unavailable" }));
|
package/lib/types/aggregate.d.ts
CHANGED
|
@@ -18,9 +18,11 @@ import type { TokenUsage } from '@deepseek-ai/dsh-llm';
|
|
|
18
18
|
*/
|
|
19
19
|
export declare const MODEL_KEY_ALIASES: Readonly<Record<string, string>>;
|
|
20
20
|
/**
|
|
21
|
-
* 走订阅套餐(coding / token /
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* 走订阅套餐(coding / token plan / opencode 订阅)的 provider id:这些通道的
|
|
22
|
+
* 调用按套餐计费,不再按 token 计费,因此即使模型 id 与计费表撞名也一律豁免。
|
|
23
|
+
* 与 pi-ai 内置提供方对齐(含各地区变体:qwen/xiaomi 的 token-plan、opencode 与
|
|
24
|
+
* opencode-go、zai-coding-cn);部署可在 plugin config 的 `subscriptionProviders`
|
|
25
|
+
* 中覆盖。
|
|
24
26
|
*/
|
|
25
27
|
export declare const DEFAULT_SUBSCRIPTION_PROVIDERS: readonly string[];
|
|
26
28
|
/** Aggregation tuning options. */
|
|
@@ -36,6 +38,8 @@ export interface ModelUsage {
|
|
|
36
38
|
cacheHit: number;
|
|
37
39
|
cacheMiss: number;
|
|
38
40
|
cost: number;
|
|
41
|
+
/** 该模型本次统计的所有调用是否都走订阅通道(coding/token plan);混合通道不置位。 */
|
|
42
|
+
plan?: boolean;
|
|
39
43
|
}
|
|
40
44
|
/** Zeroed usage accumulator. */
|
|
41
45
|
export declare function emptyUsage(): ModelUsage;
|
|
@@ -53,9 +57,11 @@ export declare function foldUsage(acc: ModelUsage, usage: TokenUsage, key: strin
|
|
|
53
57
|
export declare function dayStamp(time: number): string;
|
|
54
58
|
/**
|
|
55
59
|
* The persistence surface the aggregate reads: enough of
|
|
56
|
-
* `SessionPersistence` to list sessions and read each log once
|
|
60
|
+
* `SessionPersistence` to list sessions and read each log once; `locate`
|
|
61
|
+
* is optional — backends exposing it give the incremental cache a cheap
|
|
62
|
+
* invalidation stamp (artifact mtime + size), others always re-fold.
|
|
57
63
|
*/
|
|
58
|
-
export type UsagePersistence = Pick<SessionPersistence, 'list' | 'readFrom'
|
|
64
|
+
export type UsagePersistence = Pick<SessionPersistence, 'list' | 'readFrom'> & Partial<Pick<SessionPersistence, 'locate'>>;
|
|
59
65
|
/** The usage-stats document served to the billing dashboard. */
|
|
60
66
|
export interface UsageStatsDocument {
|
|
61
67
|
version: number;
|
|
@@ -66,12 +72,73 @@ export interface UsageStatsDocument {
|
|
|
66
72
|
byDay: Record<string, ModelUsage>;
|
|
67
73
|
/** 模型 × 日期 二维统计:趋势图按模型堆叠的输入([date][modelKey])。 */
|
|
68
74
|
byDayModels: Record<string, Record<string, ModelUsage>>;
|
|
75
|
+
/** 会话明细:按费用倒序,封顶 {@link SESSION_ROW_LIMIT} 行;旧快照可能缺失。 */
|
|
76
|
+
bySession: SessionUsageRow[];
|
|
69
77
|
}
|
|
78
|
+
/** 会话明细行:仪表盘「会话明细」面板的数据源。 */
|
|
79
|
+
export interface SessionUsageRow {
|
|
80
|
+
/** 会话 id(字符串形式)。 */
|
|
81
|
+
id: string;
|
|
82
|
+
/** 日志里最新的 session/title 文本;无标题事件时缺失。 */
|
|
83
|
+
title?: string;
|
|
84
|
+
/** 会话创建时的工作目录(项目路径);未知时缺失。 */
|
|
85
|
+
cwd?: string;
|
|
86
|
+
calls: number;
|
|
87
|
+
cost: number;
|
|
88
|
+
/** 最后一个事件的时间戳(毫秒)。 */
|
|
89
|
+
lastActive: number;
|
|
90
|
+
}
|
|
91
|
+
/** 会话明细行的响应封顶:控制 payload 体积,重度用户的完整长尾不逐行下发。 */
|
|
92
|
+
export declare const SESSION_ROW_LIMIT = 100;
|
|
93
|
+
/** 聚合文档的短 TTL(毫秒):合并密集轮询,TTL 内直接复用上次的合并结果。 */
|
|
94
|
+
export declare const AGGREGATE_TTL_MS = 5000;
|
|
95
|
+
/** One persisted session's folded usage plus drill-down metadata. */
|
|
96
|
+
interface SessionFold {
|
|
97
|
+
total: ModelUsage;
|
|
98
|
+
byModel: Map<string, ModelUsage>;
|
|
99
|
+
byDay: Map<string, ModelUsage>;
|
|
100
|
+
byDayModels: Map<string, Map<string, ModelUsage>>;
|
|
101
|
+
/** 每个模型 key 在本会话内走订阅通道的调用数(合并时跨会话累加判定 plan)。 */
|
|
102
|
+
planCalls: Map<string, number>;
|
|
103
|
+
/** 日志里最新的 session/title 文本(无标题事件时 undefined)。 */
|
|
104
|
+
title?: string;
|
|
105
|
+
/** 最后一个事件的时间戳(毫秒);空日志为 0。 */
|
|
106
|
+
lastActive: number;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Fold one session's events into a {@link SessionFold}. 每个 LLM 调用归属到
|
|
110
|
+
* 其前置 request/header 记录的模型;同时提取最新会话标题与最后活跃时间。
|
|
111
|
+
* @param events - the session's persisted events in log order.
|
|
112
|
+
* @param subscriptionProviders - provider ids billed through subscription plans.
|
|
113
|
+
* @returns the per-session fold (cached by the incremental aggregator).
|
|
114
|
+
*/
|
|
115
|
+
export declare function foldSession(events: readonly {
|
|
116
|
+
type: string;
|
|
117
|
+
time: number;
|
|
118
|
+
data: never;
|
|
119
|
+
}[], subscriptionProviders: ReadonlySet<string>): SessionFold;
|
|
120
|
+
/**
|
|
121
|
+
* 增量聚合器:按会话缓存折叠结果,用日志文件的 mtime+size 作失效键——
|
|
122
|
+
* 日志没动的会话直接复用,只有写过的会话重新折叠;整份文档另有短 TTL
|
|
123
|
+
* 合并密集轮询。缓存活在内存里(进程重启后首次全量折叠一次)。
|
|
124
|
+
*/
|
|
125
|
+
export interface UsageAggregator {
|
|
126
|
+
/** Aggregate current usage, reusing cached per-session folds when their logs are untouched. */
|
|
127
|
+
aggregate(): Promise<UsageStatsDocument>;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Create the incremental usage aggregator.
|
|
131
|
+
* @param persistence - the session persistence service.
|
|
132
|
+
* @param options - aggregation tuning (e.g. subscription-plan providers).
|
|
133
|
+
* @returns the aggregator holding the per-session fold cache.
|
|
134
|
+
*/
|
|
135
|
+
export declare function createUsageAggregator(persistence: UsagePersistence, options?: AggregateOptions): UsageAggregator;
|
|
70
136
|
/**
|
|
71
|
-
* Aggregate real usage from every persisted session log.
|
|
137
|
+
* Aggregate real usage from every persisted session log (one-shot, no cache).
|
|
72
138
|
* @param persistence - the session persistence service.
|
|
73
139
|
* @param options - aggregation tuning (e.g. subscription-plan providers).
|
|
74
140
|
* @returns the usage-stats document (same shape the dashboard expects).
|
|
75
141
|
*/
|
|
76
142
|
export declare function aggregateUsage(persistence: UsagePersistence, options?: AggregateOptions): Promise<UsageStatsDocument>;
|
|
143
|
+
export {};
|
|
77
144
|
//# sourceMappingURL=aggregate.d.ts.map
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TrendChart: dependency-free SVG chart of daily cost + calls.
|
|
3
3
|
*
|
|
4
|
-
* The columns are
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The columns are STACKED per day — one bar per day, with each model's cost
|
|
5
|
+
* as a colored segment inside the bar, so the daily total reads at a glance
|
|
6
|
+
* and the model mix stays visible. The blue line is the total call volume
|
|
7
|
+
* across all models, plotted on its own right-hand axis.
|
|
7
8
|
* A hover crosshair shows the day's model breakdown. No chart library — the
|
|
8
9
|
* surface stays self-contained and offline.
|
|
9
10
|
*/
|
|
@@ -28,7 +29,7 @@ export interface TrendPoint {
|
|
|
28
29
|
byModel?: Readonly<Record<string, number>>;
|
|
29
30
|
}
|
|
30
31
|
/**
|
|
31
|
-
* Render the daily
|
|
32
|
+
* Render the daily stacked cost bars plus the total-calls line.
|
|
32
33
|
* @param props.data - sorted daily rows (ascending date).
|
|
33
34
|
* @param props.models - the model legend, in bar order.
|
|
34
35
|
*/
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* before real data arrives the dashboard shows an empty (zero) snapshot,
|
|
10
10
|
* never fabricated samples.
|
|
11
11
|
*/
|
|
12
|
-
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
12
|
+
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots';
|
|
13
13
|
import type { SidebarFooterActionOwnerProps } from '@deepseek-ai/dsh-client-ui-sidebar/client';
|
|
14
|
+
import type { createBillingBudgetStore } from './budget-store.ts';
|
|
14
15
|
import { NS } from './locales.ts';
|
|
15
16
|
/** Model-connectivity health reported by the host model directory probe. */
|
|
16
17
|
export interface ModelHealth {
|
|
@@ -27,10 +28,35 @@ export interface ModelHealth {
|
|
|
27
28
|
/** Display names of providers whose catalog probe failed. */
|
|
28
29
|
badProviders: readonly string[];
|
|
29
30
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The dashboard's display names (中文厂商名) never equal the provider names a
|
|
33
|
+
* user actually configures (deepseek, zhipu, qwen…), so the dot match also
|
|
34
|
+
* accepts a bidirectional substring hit and a display-name alias list.
|
|
35
|
+
* 导出供一致性守卫测试:catalog 每个厂商都必须在此登记(Custom 除外),
|
|
36
|
+
* 防止新增厂商漏配导致健康绿灯不亮。
|
|
37
|
+
*/
|
|
38
|
+
export declare const PROVIDER_ALIASES: Readonly<Record<string, readonly string[]>>;
|
|
39
|
+
/**
|
|
40
|
+
* 从真实 model id 反推提供方显示名:目录未收录的模型(key 落回「其他」)
|
|
41
|
+
* 只靠 entry.provider(Custom)永远点不亮健康灯,这里用厂商别名对 model id
|
|
42
|
+
* 做强匹配(别名作为完整 id / 前缀 / 独立段)与弱匹配(长别名子串),
|
|
43
|
+
* 命中即显示厂商名并点亮健康点;无命中保持 Custom。
|
|
44
|
+
* 导出供守卫测试:短别名(mi/yi)仅允许前缀形式,防止 minimax 等误吞。
|
|
45
|
+
*/
|
|
46
|
+
export declare function providerFromModelKey(modelKey: string): string | undefined;
|
|
47
|
+
/** 组件注入面:探活 + 计费指标写入(billing 自身写入,主题插件经服务读取)。 */
|
|
48
|
+
export interface UsageBillingInjected {
|
|
32
49
|
checkModels: () => Promise<ModelHealth>;
|
|
33
|
-
|
|
50
|
+
publishCosts: (costs: {
|
|
51
|
+
todayCost: number;
|
|
52
|
+
monthCost: number;
|
|
53
|
+
}) => void;
|
|
54
|
+
registerOpen: (handler: () => void) => () => void;
|
|
55
|
+
}
|
|
56
|
+
/** 预算 store 的 props 份额(useStore 读取 + actions 写面)。 */
|
|
57
|
+
type BillingBudgetStoreProps = PropsStore<ReturnType<typeof createBillingBudgetStore>>;
|
|
58
|
+
/** Full props type for the UsageBilling component. */
|
|
59
|
+
type UsageBillingProps = PropsRuntime<'sidebar.footer.action'> & SidebarFooterActionOwnerProps & InjectFace<UsageBillingInjected> & PropsRenderSlots<'billing.dashboard.decor'> & BillingBudgetStoreProps & PropsLocale<typeof NS>;
|
|
34
60
|
/**
|
|
35
61
|
* UsageBilling: sidebar trigger plus the billing dashboard modal.
|
|
36
62
|
* @param props - framework-provided sidebar and locale props.
|
|
@@ -5,9 +5,40 @@
|
|
|
5
5
|
* Displays compact cost/token/cache metrics in the sidebar footer, above the
|
|
6
6
|
* Settings button, plus a model-health dot (green when any connected model
|
|
7
7
|
* route responds). Expands to a detailed dashboard panel on click.
|
|
8
|
+
*
|
|
9
|
+
* 与主题插件(如 acid-zine)的协作走 slot 与服务:billing 声明装饰孔位
|
|
10
|
+
*(billing.dashboard.decor)并注册计费指标服务(ctx.billingMetrics),主题
|
|
11
|
+
* 插件主动注入装饰视觉、消费费用数据——billing 不反向依赖任何主题包。
|
|
8
12
|
*/
|
|
9
13
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
|
|
10
14
|
import { type UsageBillingKey } from './locales.ts';
|
|
15
|
+
import { type BillingMetricsService } from './billing-service.ts';
|
|
16
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
17
|
+
interface SlotMap {
|
|
18
|
+
/**
|
|
19
|
+
* Dashboard 弹窗内的装饰孔位:主题插件(如 acid-zine)按 position 锚点
|
|
20
|
+
* 注入 MacDots、撕角便签、胶带标题、条码等 ZINE 元素。kind=list,可多个
|
|
21
|
+
* 注册者并列;未注入时走 billing 默认视觉。
|
|
22
|
+
*/
|
|
23
|
+
'billing.dashboard.decor': {
|
|
24
|
+
kind: 'list';
|
|
25
|
+
scope: 'root';
|
|
26
|
+
owner: BillingDashboardDecorOwnerProps;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Dashboard 装饰的锚点位置:head/headTitle=窗口标题区;hero=主数字卡;trend/models=面板标题;footer=面板底部。 */
|
|
31
|
+
export type BillingDecorPosition = 'head' | 'headTitle' | 'hero' | 'trend' | 'models' | 'footer';
|
|
32
|
+
/** Dashboard 装饰组件收到的所有者数据:当前锚点。 */
|
|
33
|
+
export interface BillingDashboardDecorOwnerProps {
|
|
34
|
+
position: BillingDecorPosition;
|
|
35
|
+
}
|
|
36
|
+
declare module '@deepseek-ai/cordis' {
|
|
37
|
+
interface Context {
|
|
38
|
+
/** 计费指标服务(billing 插件提供;主题插件可选消费)。 */
|
|
39
|
+
billingMetrics?: BillingMetricsService;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
11
42
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
12
43
|
interface LocaleNamespaceMap {
|
|
13
44
|
/** The usage billing surface's copy. */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 计费指标服务(ctx.billingMetrics):把 UsageBilling 的实时费用摘要与
|
|
3
|
+
* 弹窗打开能力开放给其他插件(如 acid-zine 主题的贴纸层)消费。
|
|
4
|
+
*
|
|
5
|
+
* 依赖方向为「billing 提供服务、主题适配消费」:billing 插件是唯一的
|
|
6
|
+
* 写入方(组件通过 inject 的 publishCosts / registerOpen 写入),消费方
|
|
7
|
+
* 只读费用快照并触发弹窗打开——billing 不反向依赖任何主题包。
|
|
8
|
+
*/
|
|
9
|
+
/** 计费摘要(精简版,仅供外部展示;金额为人民币元)。 */
|
|
10
|
+
export interface BillingCosts {
|
|
11
|
+
todayCost: number;
|
|
12
|
+
monthCost: number;
|
|
13
|
+
}
|
|
14
|
+
/** 消费方(如酸-zine 贴纸层)可用的只读接口。 */
|
|
15
|
+
export interface BillingMetricsService {
|
|
16
|
+
/** 当前费用快照;从未发布过则 undefined。 */
|
|
17
|
+
readCosts(): BillingCosts | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* 订阅费用更新;立即收到当前值一次(若无值则为 undefined)。
|
|
20
|
+
* @param listener - 费用回调。
|
|
21
|
+
* @returns 退订函数。
|
|
22
|
+
*/
|
|
23
|
+
subscribeCosts(listener: (costs: BillingCosts | undefined) => void): () => void;
|
|
24
|
+
/** 打开计费仪表盘(若 billing 弹窗已挂载)。 */
|
|
25
|
+
openDashboard(): void;
|
|
26
|
+
}
|
|
27
|
+
/** 服务运行时:在只读接口之上追加写入入口,仅 apply 与组件使用。 */
|
|
28
|
+
export interface BillingMetricsRuntime extends BillingMetricsService {
|
|
29
|
+
/** 发布最新费用摘要(UsageBilling 组件每次渲染数据变化时调用)。 */
|
|
30
|
+
publishCosts(costs: BillingCosts): void;
|
|
31
|
+
/**
|
|
32
|
+
* 注册弹窗打开回调;组件卸载时应解除。
|
|
33
|
+
* @param handler - 打开弹窗的处理函数。
|
|
34
|
+
* @returns 解除注册的函数。
|
|
35
|
+
*/
|
|
36
|
+
registerOpen(handler: () => void): () => void;
|
|
37
|
+
}
|
|
38
|
+
/** 创建计费指标运行时(apply 内调用,随插件纤维存活)。 */
|
|
39
|
+
export declare function createBillingMetrics(): BillingMetricsRuntime;
|
|
40
|
+
//# sourceMappingURL=billing-service.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 预算偏好 store:本月预算的开关与金额。
|
|
3
|
+
*
|
|
4
|
+
* 用户在仪表盘里用开关控制预算条显隐、用数字输入框设置金额;状态经框架
|
|
5
|
+
* store 引擎持久化到 localStorage(persist key 即存储身份),重启后保留。
|
|
6
|
+
* 宿主 Config 的 monthlyBudget 仅作为金额未设置时的默认值,用户输入优先。
|
|
7
|
+
*/
|
|
8
|
+
import { type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client';
|
|
9
|
+
/** 预算偏好状态。 */
|
|
10
|
+
export interface BudgetPrefsState {
|
|
11
|
+
/** 预算条开关:关 = 只显示标题行与开关,不显示进度。 */
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
/** 用户设置的月度预算(人民币元);0 = 未设置(回退到宿主默认值)。 */
|
|
14
|
+
amount: number;
|
|
15
|
+
/** 最近一次超支通知的日期戳(YYYY-MM-DD):超支通知每天最多一次,跨重启生效。 */
|
|
16
|
+
lastAlertDay: string;
|
|
17
|
+
/** 最近一次余额不足通知的日期戳(YYYY-MM-DD):余额告警同样每天最多一次。 */
|
|
18
|
+
lastBalanceAlertDay: string;
|
|
19
|
+
}
|
|
20
|
+
/** 预算偏好的完整写面(组件只能经这些 action 写入);type 别名以兼容 ActionsDecl 的索引签名约束。 */
|
|
21
|
+
export type BudgetPrefsActions = {
|
|
22
|
+
setEnabled: (d: BudgetPrefsState, on: boolean) => void;
|
|
23
|
+
setAmount: (d: BudgetPrefsState, value: number) => void;
|
|
24
|
+
markAlerted: (d: BudgetPrefsState, day: string) => void;
|
|
25
|
+
markBalanceAlerted: (d: BudgetPrefsState, day: string) => void;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Declare the budget-preferences store handle.
|
|
29
|
+
* @returns the store handle for the register call's store seat.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createBillingBudgetStore(): EngineStoreHandle<BudgetPrefsState, BudgetPrefsActions>;
|
|
32
|
+
//# sourceMappingURL=budget-store.d.ts.map
|
|
@@ -8,4 +8,6 @@
|
|
|
8
8
|
export { inject, apply } from './apply.ts';
|
|
9
9
|
export { UsageBilling } from './UsageBilling.tsx';
|
|
10
10
|
export type { UsageBillingKey } from './locales.ts';
|
|
11
|
+
export type { BillingCosts, BillingMetricsService } from './billing-service.ts';
|
|
12
|
+
export type { BillingDecorPosition, BillingDashboardDecorOwnerProps } from './apply.ts';
|
|
11
13
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Locale dictionaries for the usage billing surface. */
|
|
2
|
-
export type UsageBillingKey = 'billing.title' | 'billing.subtitle' | 'billing.cost' | 'billing.todayCost' | 'billing.monthCost' | 'billing.yearCost' | 'billing.totalCost' | 'billing.calls' | 'billing.cacheHitRate' | 'billing.tokens' | 'billing.inputTokens' | 'billing.outputTokens' | 'billing.avgCost' | 'billing.trend' | 'billing.trendEmpty' | 'billing.models' | 'billing.estimated' | 'billing.actual' | 'billing.pricing' | 'billing.showPricing' | 'billing.hidePricing' | 'billing.pricePerM' | 'billing.input' | 'billing.output' | 'billing.cacheHit' | 'billing.peak' | 'billing.offPeak' | 'billing.flat' | 'billing.peakHours' | 'billing.band' | 'billing.openDashboard' | 'billing.close' | 'billing.lastUpdated' | 'billing.noData' | 'billing.todayRate' | 'billing.rateLive' | 'billing.rateBuiltin' | 'billing.balance' | 'billing.balanceUnconfigured' | 'billing.balanceUnauthorized' | 'billing.balanceUnreachable';
|
|
2
|
+
export type UsageBillingKey = 'billing.title' | 'billing.subtitle' | 'billing.cost' | 'billing.todayCost' | 'billing.monthCost' | 'billing.yearCost' | 'billing.totalCost' | 'billing.calls' | 'billing.cacheHitRate' | 'billing.tokens' | 'billing.inputTokens' | 'billing.outputTokens' | 'billing.avgCost' | 'billing.trend' | 'billing.trend7d' | 'billing.trend30d' | 'billing.trendEmpty' | 'billing.budget' | 'billing.sessions' | 'billing.project' | 'billing.lastActive' | 'billing.sessionOverflow' | 'billing.budgetOverBody' | 'billing.models' | 'billing.estimated' | 'billing.actual' | 'billing.pricing' | 'billing.showPricing' | 'billing.hidePricing' | 'billing.pricePerM' | 'billing.input' | 'billing.output' | 'billing.cacheHit' | 'billing.peak' | 'billing.offPeak' | 'billing.flat' | 'billing.peakHours' | 'billing.band' | 'billing.openDashboard' | 'billing.close' | 'billing.lastUpdated' | 'billing.noData' | 'billing.todayRate' | 'billing.rateLive' | 'billing.rateBuiltin' | 'billing.balance' | 'billing.balanceUnconfigured' | 'billing.balanceUnauthorized' | 'billing.balanceUnreachable' | 'billing.uncatalogued' | 'billing.balanceDays' | 'billing.balanceLowBody';
|
|
3
3
|
export declare const NS = "usageBilling";
|
|
4
4
|
export declare const zh: Record<UsageBillingKey, string>;
|
|
5
5
|
export declare const en: Record<UsageBillingKey, string>;
|