@kenz1117/dsh-ui-usage-billing 0.5.0 → 0.7.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 +21 -8
- package/lib/client.js +1196 -492
- package/lib/index.js +511 -20
- package/lib/types/aggregate.d.ts +28 -0
- package/lib/types/balance.d.ts +17 -1
- package/lib/types/client/UsageBilling.d.ts +29 -1
- package/lib/types/client/budget-store.d.ts +6 -3
- package/lib/types/client/export.d.ts +33 -0
- package/lib/types/client/live-cost.d.ts +27 -1
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/pricing.d.ts +53 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/pricing-fetch.d.ts +9 -1
- package/lib/types/pricing-shared.d.ts +50 -0
- package/package.json +3 -1
package/lib/index.js
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
5
|
+
import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
4
6
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
7
|
+
/** 运行时实时覆盖:undefined = 用内置目录与内置汇率(默认值降级)。 */
|
|
8
|
+
let liveRate;
|
|
9
|
+
let livePrices;
|
|
10
|
+
let liveExtraModels;
|
|
11
|
+
/**
|
|
12
|
+
* Apply the node half's live pricing snapshot. Absent fields keep the
|
|
13
|
+
* built-in catalog and rate; callers never fabricate values.
|
|
14
|
+
* @param pricing - the `/api/billing/pricing` response.
|
|
15
|
+
*/
|
|
16
|
+
function applyLivePricing(pricing) {
|
|
17
|
+
liveRate = pricing.rate;
|
|
18
|
+
livePrices = pricing.prices;
|
|
19
|
+
liveExtraModels = pricing.extraModels;
|
|
20
|
+
}
|
|
5
21
|
/** 当前汇率:实时覆盖优先,缺省回退内置固定值。 */
|
|
6
22
|
function currentRate() {
|
|
7
|
-
return 6.79;
|
|
23
|
+
return liveRate ?? 6.79;
|
|
8
24
|
}
|
|
9
25
|
/** Default share of traffic assumed to fall in the peak band (0..1). */
|
|
10
26
|
const DEFAULT_PEAK_SHARE = .5;
|
|
@@ -542,11 +558,48 @@ const MODEL_KEY_ALIASES = {
|
|
|
542
558
|
/** Lookup a model by its stats key; falls back to the generic `other` entry. */
|
|
543
559
|
function modelOf(key) {
|
|
544
560
|
const resolved = MODEL_KEY_ALIASES[key] ?? key;
|
|
545
|
-
|
|
561
|
+
const found = MODEL_CATALOG.find((entry) => entry.key === resolved);
|
|
562
|
+
const extra = liveExtraModels?.find((item) => item.key === resolved);
|
|
563
|
+
const base = found ?? (extra !== void 0 ? extraEntryOf(extra) : (() => {
|
|
546
564
|
const fallback = MODEL_CATALOG.at(-1);
|
|
547
565
|
if (fallback !== void 0) return fallback;
|
|
548
566
|
throw new Error("MODEL_CATALOG must not be empty");
|
|
549
|
-
})();
|
|
567
|
+
})());
|
|
568
|
+
const live = livePrices?.[resolved];
|
|
569
|
+
if (live === void 0) return base;
|
|
570
|
+
return {
|
|
571
|
+
...base,
|
|
572
|
+
price: {
|
|
573
|
+
currency: "USD",
|
|
574
|
+
input: live.input,
|
|
575
|
+
cacheHit: live.cacheHit,
|
|
576
|
+
output: live.output
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
/** models.dev 补充条目转为目录条目:USD 直价(走汇率换算),无峰谷分档。 */
|
|
581
|
+
function extraEntryOf(extra) {
|
|
582
|
+
return {
|
|
583
|
+
key: extra.key,
|
|
584
|
+
name: extra.name,
|
|
585
|
+
provider: extra.provider,
|
|
586
|
+
colorVar: "dsw-static-neutral-400",
|
|
587
|
+
price: {
|
|
588
|
+
currency: "USD",
|
|
589
|
+
input: extra.price.input,
|
|
590
|
+
cacheHit: extra.price.cacheHit,
|
|
591
|
+
output: extra.price.output
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* 模型是否可计价:内置目录或 models.dev 补充条目命中。聚合层的计价闸门
|
|
597
|
+
* (目录外模型不产生费用,避免兜底档误估)。
|
|
598
|
+
*/
|
|
599
|
+
function isPriced(key) {
|
|
600
|
+
const resolved = MODEL_KEY_ALIASES[key] ?? key;
|
|
601
|
+
if (MODEL_CATALOG.some((entry) => entry.key === resolved)) return true;
|
|
602
|
+
return (liveExtraModels ?? []).some((item) => item.key === resolved);
|
|
550
603
|
}
|
|
551
604
|
/**
|
|
552
605
|
* Price one band's token usage in CNY. The stats `input` field is the TOTAL
|
|
@@ -597,6 +650,28 @@ function computeCostAt(entry, buckets, timeMs, peakShare = DEFAULT_PEAK_SHARE) {
|
|
|
597
650
|
if (timeMs === null || timeMs === void 0 || !Number.isFinite(timeMs)) return computeCost(entry, buckets, peakShare);
|
|
598
651
|
return priceBandCost(tierAt(timeMs) === "peak" ? entry.price : entry.price.offPeak, buckets, entry.price.currency);
|
|
599
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Format an amount with adaptive precision and the given currency symbol.
|
|
655
|
+
* @param amount - the amount (CNY by default; pass `usd` for dollar display).
|
|
656
|
+
* @param currency - display currency; default `cny`.
|
|
657
|
+
*/
|
|
658
|
+
function formatMoney(amount, currency = "cny") {
|
|
659
|
+
const value = Number(amount);
|
|
660
|
+
if (!Number.isFinite(value)) return currency === "cny" ? "¥0" : "$0";
|
|
661
|
+
const symbol = currency === "cny" ? "¥" : "$";
|
|
662
|
+
if (value <= 0) return `${symbol}0`;
|
|
663
|
+
if (value >= 1e3) return `${symbol}${value.toFixed(0)}`;
|
|
664
|
+
if (value >= 10) return `${symbol}${value.toFixed(1)}`;
|
|
665
|
+
if (value >= .1) return `${symbol}${value.toFixed(2)}`;
|
|
666
|
+
return `${symbol}${value.toFixed(3)}`;
|
|
667
|
+
}
|
|
668
|
+
/** Format a large token count with B/M/K suffix. */
|
|
669
|
+
function formatTokens(value) {
|
|
670
|
+
if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
|
671
|
+
if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
|
|
672
|
+
if (value >= 1e3) return `${(value / 1e3).toFixed(0)}K`;
|
|
673
|
+
return String(value);
|
|
674
|
+
}
|
|
600
675
|
//#endregion
|
|
601
676
|
//#region lib/types/aggregate.js
|
|
602
677
|
/**
|
|
@@ -657,7 +732,7 @@ function foldUsage(acc, usage, key, subscription, timeMs) {
|
|
|
657
732
|
acc.output += usage.outputTokens;
|
|
658
733
|
acc.cacheHit += cacheHit;
|
|
659
734
|
acc.cacheMiss += cacheMiss;
|
|
660
|
-
if (!subscription &&
|
|
735
|
+
if (!subscription && isPriced(key)) acc.cost += computeCostAt(modelOf(key), {
|
|
661
736
|
input: cacheHit + cacheMiss,
|
|
662
737
|
cacheHit,
|
|
663
738
|
cacheMiss,
|
|
@@ -675,6 +750,22 @@ function workspaceNameOf(cwd) {
|
|
|
675
750
|
if (cwd === void 0 || cwd === "") return "—";
|
|
676
751
|
return cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? "—";
|
|
677
752
|
}
|
|
753
|
+
/**
|
|
754
|
+
* 消息文本长度:user/tool 角色分摊输入成本的启发式依据。字符串内容取其
|
|
755
|
+
* 长度;内容块数组累计文本块长度;其余形状按 0 计(durable 边界收窄)。
|
|
756
|
+
*/
|
|
757
|
+
function messageTextLength(message) {
|
|
758
|
+
if (message === null || typeof message !== "object") return 0;
|
|
759
|
+
const content = message.content;
|
|
760
|
+
if (typeof content === "string") return content.length;
|
|
761
|
+
if (!Array.isArray(content)) return 0;
|
|
762
|
+
let total = 0;
|
|
763
|
+
for (const block of content) {
|
|
764
|
+
const text = block?.text;
|
|
765
|
+
if (typeof text === "string") total += text.length;
|
|
766
|
+
}
|
|
767
|
+
return total;
|
|
768
|
+
}
|
|
678
769
|
/** Get-or-create one model cell inside a usage map (avoids non-null assertions). */
|
|
679
770
|
function usageCell(map, key) {
|
|
680
771
|
const existing = map.get(key);
|
|
@@ -725,6 +816,12 @@ function foldSession(events, subscriptionProviders) {
|
|
|
725
816
|
byDayModels: /* @__PURE__ */ new Map(),
|
|
726
817
|
planCalls: /* @__PURE__ */ new Map(),
|
|
727
818
|
turns: [],
|
|
819
|
+
roles: {
|
|
820
|
+
userChars: 0,
|
|
821
|
+
toolChars: 0,
|
|
822
|
+
inputCost: 0,
|
|
823
|
+
outputCost: 0
|
|
824
|
+
},
|
|
728
825
|
lastActive: 0
|
|
729
826
|
};
|
|
730
827
|
let key = "other";
|
|
@@ -737,6 +834,14 @@ function foldSession(events, subscriptionProviders) {
|
|
|
737
834
|
if (typeof title === "string" && title.length > 0) fold.title = title;
|
|
738
835
|
continue;
|
|
739
836
|
}
|
|
837
|
+
if (event.type === "user/message") {
|
|
838
|
+
fold.roles.userChars += messageTextLength(event.data.message);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
if (event.type === "tool/result") {
|
|
842
|
+
fold.roles.toolChars += messageTextLength(event.data.message);
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
740
845
|
if (event.type === "turn/start") {
|
|
741
846
|
const state = turnState(turns, event.data.turn ?? -1);
|
|
742
847
|
if (event.time < state.startedAt) state.startedAt = event.time;
|
|
@@ -770,12 +875,24 @@ function foldSession(events, subscriptionProviders) {
|
|
|
770
875
|
state.output += usage.outputTokens;
|
|
771
876
|
state.cacheHit += usage.cacheReadTokens ?? 0;
|
|
772
877
|
state.cacheMiss += usage.inputTokens + (usage.cacheWriteTokens ?? 0);
|
|
773
|
-
if (!subscription &&
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
878
|
+
if (!subscription && isPriced(modelKey)) {
|
|
879
|
+
const buckets = {
|
|
880
|
+
input: (usage.cacheReadTokens ?? 0) + usage.inputTokens + (usage.cacheWriteTokens ?? 0),
|
|
881
|
+
cacheHit: usage.cacheReadTokens ?? 0,
|
|
882
|
+
cacheMiss: usage.inputTokens + (usage.cacheWriteTokens ?? 0),
|
|
883
|
+
output: usage.outputTokens
|
|
884
|
+
};
|
|
885
|
+
const fullCost = computeCostAt(modelOf(modelKey), buckets, event.time);
|
|
886
|
+
state.cost += fullCost;
|
|
887
|
+
const outputCost = computeCostAt(modelOf(modelKey), {
|
|
888
|
+
input: 0,
|
|
889
|
+
cacheHit: 0,
|
|
890
|
+
cacheMiss: 0,
|
|
891
|
+
output: usage.outputTokens
|
|
892
|
+
}, event.time);
|
|
893
|
+
fold.roles.outputCost += outputCost;
|
|
894
|
+
fold.roles.inputCost += fullCost - outputCost;
|
|
895
|
+
}
|
|
779
896
|
if (state.startedAt === Number.MAX_SAFE_INTEGER) state.startedAt = event.time;
|
|
780
897
|
}
|
|
781
898
|
fold.turns = [...turns.values()].filter((state) => state.input > 0 || state.output > 0).sort((a, b) => a.turn - b.turn).map((state) => ({
|
|
@@ -860,9 +977,19 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
860
977
|
const sessionRows = [];
|
|
861
978
|
const turnRows = [];
|
|
862
979
|
const workspaceMap = /* @__PURE__ */ new Map();
|
|
980
|
+
const roles = {
|
|
981
|
+
userChars: 0,
|
|
982
|
+
toolChars: 0,
|
|
983
|
+
inputCost: 0,
|
|
984
|
+
outputCost: 0
|
|
985
|
+
};
|
|
863
986
|
for (const { meta, fold } of folds) {
|
|
864
987
|
const sessionId = String(meta.id);
|
|
865
988
|
mergeUsageInto(total, fold.total);
|
|
989
|
+
roles.userChars += fold.roles.userChars;
|
|
990
|
+
roles.toolChars += fold.roles.toolChars;
|
|
991
|
+
roles.inputCost += fold.roles.inputCost;
|
|
992
|
+
roles.outputCost += fold.roles.outputCost;
|
|
866
993
|
for (const [modelKey, cell] of fold.byModel) mergeUsageInto(usageCell(byModel, modelKey), cell);
|
|
867
994
|
for (const [day, cell] of fold.byDay) mergeUsageInto(usageCell(byDay, day), cell);
|
|
868
995
|
for (const [day, models] of fold.byDayModels) for (const [modelKey, cell] of models) mergeUsageInto(modelDayCell(byDayModels, day, modelKey), cell);
|
|
@@ -918,7 +1045,16 @@ function createUsageAggregator(persistence, options = {}) {
|
|
|
918
1045
|
byDayModels: toModelDayRecord(byDayModels),
|
|
919
1046
|
bySession: sessionRows.slice(0, 100),
|
|
920
1047
|
byTurn: turnRows.slice(0, 200),
|
|
921
|
-
byWorkspace: workspaces.slice(0, 100)
|
|
1048
|
+
byWorkspace: workspaces.slice(0, 100),
|
|
1049
|
+
byRole: (() => {
|
|
1050
|
+
const chars = roles.userChars + roles.toolChars;
|
|
1051
|
+
const userShare = chars > 0 ? roles.userChars / chars : .5;
|
|
1052
|
+
return {
|
|
1053
|
+
user: roles.inputCost * userShare,
|
|
1054
|
+
assistant: roles.outputCost,
|
|
1055
|
+
tool: roles.inputCost * (1 - userShare)
|
|
1056
|
+
};
|
|
1057
|
+
})()
|
|
922
1058
|
};
|
|
923
1059
|
lastAt = now;
|
|
924
1060
|
return lastDoc;
|
|
@@ -1113,6 +1249,125 @@ async function queryBalances(ctx, providers) {
|
|
|
1113
1249
|
return querier(ctx, env);
|
|
1114
1250
|
}));
|
|
1115
1251
|
}
|
|
1252
|
+
/** 点路径取值:`data.total_available` → 逐层下钻;任一缺失返回 undefined。 */
|
|
1253
|
+
function getPath(data, path) {
|
|
1254
|
+
let cursor = data;
|
|
1255
|
+
for (const segment of path.split(".")) {
|
|
1256
|
+
if (cursor === null || typeof cursor !== "object") return void 0;
|
|
1257
|
+
cursor = cursor[segment];
|
|
1258
|
+
}
|
|
1259
|
+
return cursor;
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* 按 extract 规则从响应 JSON 求值。导出供测试:纯函数。
|
|
1263
|
+
* @param rule - 提取规则(const / path / add / subtract / divide)。
|
|
1264
|
+
* @param data - 响应 JSON。
|
|
1265
|
+
* @returns 数值;取不到或结果非有限数返回 undefined。
|
|
1266
|
+
*/
|
|
1267
|
+
function evalExtract(rule, data) {
|
|
1268
|
+
if (typeof rule.const === "number" && Number.isFinite(rule.const)) return rule.const;
|
|
1269
|
+
if (rule.op === "add" || rule.op === "subtract") {
|
|
1270
|
+
const paths = rule.paths ?? [];
|
|
1271
|
+
if (paths.length === 0) return void 0;
|
|
1272
|
+
let total;
|
|
1273
|
+
for (const path of paths) {
|
|
1274
|
+
const value = toNumber(getPath(data, path));
|
|
1275
|
+
if (value === void 0) return void 0;
|
|
1276
|
+
total = total === void 0 ? value : rule.op === "add" ? total + value : total - value;
|
|
1277
|
+
}
|
|
1278
|
+
return total;
|
|
1279
|
+
}
|
|
1280
|
+
const base = typeof rule.path === "string" ? toNumber(getPath(data, rule.path)) : void 0;
|
|
1281
|
+
if (base === void 0) return void 0;
|
|
1282
|
+
if (rule.op === "divide") {
|
|
1283
|
+
const by = rule.by;
|
|
1284
|
+
if (typeof by !== "number" || !Number.isFinite(by) || by === 0) return void 0;
|
|
1285
|
+
return base / by;
|
|
1286
|
+
}
|
|
1287
|
+
return base;
|
|
1288
|
+
}
|
|
1289
|
+
/** 请求头占位符解析:`{{ENV_NAME}}` 经凭据 seam 替换;解析失败返回 null。 */
|
|
1290
|
+
async function resolveHeaders(ctx, headers) {
|
|
1291
|
+
const resolved = {};
|
|
1292
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1293
|
+
const match = /^\{\{([A-Z0-9_]+)\}\}$/i.exec(value.trim());
|
|
1294
|
+
if (match === null) {
|
|
1295
|
+
resolved[key] = value;
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
const hit = await ctx.credentials.resolve(credentialRef(match[1] ?? ""));
|
|
1299
|
+
if (hit === void 0 || hit.value === "") return null;
|
|
1300
|
+
resolved[key] = value.replace(match[0], hit.value);
|
|
1301
|
+
}
|
|
1302
|
+
return resolved;
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* 查询自定义 Provider 余额(插件 config 的 `customBalances`)。每个条目独立
|
|
1306
|
+
* 成败:占位符凭据缺失 → unconfigured;401/403 → unauthorized;网络或提取
|
|
1307
|
+
* 失败 → unreachable。
|
|
1308
|
+
* @param ctx - host context carrying the credentials seam.
|
|
1309
|
+
* @param configs - 自定义余额配置列表。
|
|
1310
|
+
* @returns 每个配置一行的余额结果。
|
|
1311
|
+
*/
|
|
1312
|
+
async function queryCustomBalances(ctx, configs) {
|
|
1313
|
+
return await Promise.all(configs.map(async (config) => {
|
|
1314
|
+
const provider = `custom:${config.label}`;
|
|
1315
|
+
const displayName = config.label;
|
|
1316
|
+
if (typeof config.url !== "string" || config.url === "") return {
|
|
1317
|
+
provider,
|
|
1318
|
+
displayName,
|
|
1319
|
+
error: "unconfigured"
|
|
1320
|
+
};
|
|
1321
|
+
const headers = await resolveHeaders(ctx, config.headers ?? {});
|
|
1322
|
+
if (headers === null) return {
|
|
1323
|
+
provider,
|
|
1324
|
+
displayName,
|
|
1325
|
+
error: "unconfigured"
|
|
1326
|
+
};
|
|
1327
|
+
const controller = new AbortController();
|
|
1328
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS$1);
|
|
1329
|
+
try {
|
|
1330
|
+
const response = await fetch(config.url, {
|
|
1331
|
+
method: config.method ?? "GET",
|
|
1332
|
+
headers: {
|
|
1333
|
+
accept: "application/json",
|
|
1334
|
+
...headers
|
|
1335
|
+
},
|
|
1336
|
+
signal: controller.signal
|
|
1337
|
+
});
|
|
1338
|
+
if (response.status === 401 || response.status === 403) return {
|
|
1339
|
+
provider,
|
|
1340
|
+
displayName,
|
|
1341
|
+
error: "unauthorized"
|
|
1342
|
+
};
|
|
1343
|
+
if (!response.ok) return {
|
|
1344
|
+
provider,
|
|
1345
|
+
displayName,
|
|
1346
|
+
error: "unreachable"
|
|
1347
|
+
};
|
|
1348
|
+
const remaining = evalExtract(config.extract.remaining, await response.json());
|
|
1349
|
+
if (remaining === void 0) return {
|
|
1350
|
+
provider,
|
|
1351
|
+
displayName,
|
|
1352
|
+
error: "unreachable"
|
|
1353
|
+
};
|
|
1354
|
+
return {
|
|
1355
|
+
provider,
|
|
1356
|
+
displayName,
|
|
1357
|
+
currency: config.unit ?? "CNY",
|
|
1358
|
+
totalBalance: remaining
|
|
1359
|
+
};
|
|
1360
|
+
} catch {
|
|
1361
|
+
return {
|
|
1362
|
+
provider,
|
|
1363
|
+
displayName,
|
|
1364
|
+
error: "unreachable"
|
|
1365
|
+
};
|
|
1366
|
+
} finally {
|
|
1367
|
+
clearTimeout(timer);
|
|
1368
|
+
}
|
|
1369
|
+
}));
|
|
1370
|
+
}
|
|
1116
1371
|
//#endregion
|
|
1117
1372
|
//#region lib/types/pricing-fetch.js
|
|
1118
1373
|
/**
|
|
@@ -1150,6 +1405,41 @@ const RATE_SOURCES = [{
|
|
|
1150
1405
|
}];
|
|
1151
1406
|
/** OpenRouter's public model list: per-token USD prices, no key needed. */
|
|
1152
1407
|
const ROUTER_URL = "https://openrouter.ai/api/v1/models";
|
|
1408
|
+
/** models.dev 公开目录:pi-ai 预制提供方的上游数据源(USD / 1M tokens)。
|
|
1409
|
+
* 接入它即与宿主「系统设置里预制的提供方模型」对齐——预制条目来自同一份数据。 */
|
|
1410
|
+
const MODELS_DEV_URL = "https://models.dev/api.json";
|
|
1411
|
+
/**
|
|
1412
|
+
* models.dev provider id → 仪表盘厂商显示名。探测到的模型若其厂商显示名与此
|
|
1413
|
+
* 映射命中则用之;未命中的回退为探活模型自带的厂商名(系统配置里的显示名)。
|
|
1414
|
+
* 不作为过滤条件——只用于给 models.dev 条目补一个可读的厂商名。
|
|
1415
|
+
*/
|
|
1416
|
+
const MODELS_DEV_PROVIDERS = {
|
|
1417
|
+
deepseek: "DeepSeek",
|
|
1418
|
+
zhipu: "智谱 AI",
|
|
1419
|
+
zhipuai: "智谱 AI",
|
|
1420
|
+
zai: "智谱 AI",
|
|
1421
|
+
qwen: "阿里通义",
|
|
1422
|
+
alibaba: "阿里通义",
|
|
1423
|
+
moonshot: "月之暗面",
|
|
1424
|
+
moonshotai: "月之暗面",
|
|
1425
|
+
volcengine: "字节豆包",
|
|
1426
|
+
doubao: "字节豆包",
|
|
1427
|
+
minimax: "MiniMax",
|
|
1428
|
+
baidu: "百度文心",
|
|
1429
|
+
tencent: "腾讯混元",
|
|
1430
|
+
hunyuan: "腾讯混元",
|
|
1431
|
+
stepfun: "阶跃星辰",
|
|
1432
|
+
iflytek: "科大讯飞",
|
|
1433
|
+
sensetime: "商汤",
|
|
1434
|
+
baichuan: "百川智能",
|
|
1435
|
+
"01.ai": "零一万物",
|
|
1436
|
+
openai: "OpenAI",
|
|
1437
|
+
google: "Google",
|
|
1438
|
+
xai: "xAI",
|
|
1439
|
+
meta: "Meta",
|
|
1440
|
+
anthropic: "Anthropic",
|
|
1441
|
+
mistral: "Mistral"
|
|
1442
|
+
};
|
|
1153
1443
|
/**
|
|
1154
1444
|
* Built-in catalog key → OpenRouter model-id candidates. Matching prefers an
|
|
1155
1445
|
* exact id, then a single strong substring hit (the router id contains the
|
|
@@ -1250,19 +1540,70 @@ function buildPrices(models) {
|
|
|
1250
1540
|
}
|
|
1251
1541
|
return Object.keys(result).length > 0 ? result : void 0;
|
|
1252
1542
|
}
|
|
1543
|
+
/** 有限正数收窄(models.dev cost 字段的 durable 边界)。 */
|
|
1544
|
+
function asPrice(value) {
|
|
1545
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* models.dev 响应 → 目录外补充条目。不再按厂商白名单过滤:凡是有有效
|
|
1549
|
+
* cost 的模型都纳入(探活模型可能来自任何预制厂商,白名单会漏掉)。厂商
|
|
1550
|
+
* 显示名优先取映射,未命中用 provider id。导出供测试:纯函数。
|
|
1551
|
+
* @param data - `https://models.dev/api.json` 的响应体。
|
|
1552
|
+
* @returns 补充条目(按 provider 顺序稳定;仅含可计价的模型)。
|
|
1553
|
+
*/
|
|
1554
|
+
function buildExtraModels(data) {
|
|
1555
|
+
if (data === null || typeof data !== "object") return [];
|
|
1556
|
+
const catalogKeys = new Set([...MODEL_CATALOG.map((entry) => entry.key.toLowerCase()), ...Object.keys(MODEL_KEY_ALIASES).map((key) => MODEL_KEY_ALIASES[key]?.toLowerCase() ?? key.toLowerCase())]);
|
|
1557
|
+
const extras = [];
|
|
1558
|
+
for (const [providerId, providerDoc] of Object.entries(data)) {
|
|
1559
|
+
if (providerDoc === null || typeof providerDoc !== "object") continue;
|
|
1560
|
+
const models = providerDoc.models;
|
|
1561
|
+
if (models === null || typeof models !== "object") continue;
|
|
1562
|
+
for (const [modelId, modelDoc] of Object.entries(models)) {
|
|
1563
|
+
const catalogKey = (MODEL_KEY_ALIASES[modelId] ?? modelId).toLowerCase();
|
|
1564
|
+
if (catalogKeys.has(catalogKey)) continue;
|
|
1565
|
+
const key = catalogKey;
|
|
1566
|
+
if (modelDoc === null || typeof modelDoc !== "object") continue;
|
|
1567
|
+
const cost = modelDoc.cost;
|
|
1568
|
+
if (cost === null || typeof cost !== "object") continue;
|
|
1569
|
+
const input = asPrice(cost.input);
|
|
1570
|
+
const output = asPrice(cost.output);
|
|
1571
|
+
if (input === void 0 || output === void 0) continue;
|
|
1572
|
+
const cacheRead = asPrice(cost.cache_read) ?? input * .1;
|
|
1573
|
+
const name = modelDoc.name;
|
|
1574
|
+
extras.push({
|
|
1575
|
+
key,
|
|
1576
|
+
name: typeof name === "string" && name !== "" ? name : modelId,
|
|
1577
|
+
provider: MODELS_DEV_PROVIDERS[providerId.toLowerCase()] ?? providerId,
|
|
1578
|
+
price: {
|
|
1579
|
+
input,
|
|
1580
|
+
cacheHit: cacheRead,
|
|
1581
|
+
output
|
|
1582
|
+
}
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return extras;
|
|
1587
|
+
}
|
|
1253
1588
|
/**
|
|
1254
1589
|
* Fetch the live pricing once at boot. Both upstreams run in parallel; a
|
|
1255
1590
|
* failure in either degrades independently to the built-in value.
|
|
1256
1591
|
* @returns the live pricing snapshot (builtin when everything failed).
|
|
1257
1592
|
*/
|
|
1258
1593
|
async function fetchLivePricing() {
|
|
1259
|
-
const [rate, models] = await Promise.all([
|
|
1594
|
+
const [rate, models, modelsDev] = await Promise.all([
|
|
1595
|
+
fetchRate(),
|
|
1596
|
+
fetchRouterModels(),
|
|
1597
|
+
fetchJson(MODELS_DEV_URL)
|
|
1598
|
+
]);
|
|
1260
1599
|
const prices = models === void 0 ? void 0 : buildPrices(models);
|
|
1261
|
-
|
|
1600
|
+
const extraModels = modelsDev === null ? void 0 : buildExtraModels(modelsDev);
|
|
1601
|
+
if (rate === void 0 && prices === void 0 && (extraModels === void 0 || extraModels.length === 0)) return { source: "builtin" };
|
|
1262
1602
|
return {
|
|
1263
1603
|
source: "live",
|
|
1264
1604
|
...rate !== void 0 ? { rate } : {},
|
|
1265
|
-
...prices !== void 0 ? { prices } : {}
|
|
1605
|
+
...prices !== void 0 ? { prices } : {},
|
|
1606
|
+
...extraModels !== void 0 && extraModels.length > 0 ? { extraModels } : {}
|
|
1266
1607
|
};
|
|
1267
1608
|
}
|
|
1268
1609
|
//#endregion
|
|
@@ -1400,8 +1741,8 @@ function kimiWindow(value, kind) {
|
|
|
1400
1741
|
const record = value;
|
|
1401
1742
|
const limit = numberOrNull(record.limit ?? record.total);
|
|
1402
1743
|
const remaining = numberOrNull(record.remaining);
|
|
1403
|
-
if (
|
|
1404
|
-
const usedPercent = round1(clampPercent((limit - remaining) / limit * 100) ?? 0);
|
|
1744
|
+
if (remaining === null) return null;
|
|
1745
|
+
const usedPercent = limit !== null && limit > 0 ? round1(clampPercent((limit - remaining) / limit * 100) ?? 0) : remaining > 0 ? 0 : 100;
|
|
1405
1746
|
const resetsAt = toIso(record.resetTime ?? record.reset_time ?? record.resetsAt);
|
|
1406
1747
|
return {
|
|
1407
1748
|
kind,
|
|
@@ -1676,6 +2017,8 @@ const PRICING_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
|
|
|
1676
2017
|
const SUBSCRIPTION_CACHE_MS = 300 * 1e3;
|
|
1677
2018
|
/** DeepSeek 余额查询的默认凭据引用(与 llm-deepseek 的默认引用一致)。 */
|
|
1678
2019
|
const DEFAULT_BALANCE_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
2020
|
+
/** 统计快照的落盘节流(毫秒):前端 30 秒轮询,快照最多每 30 秒写一次。 */
|
|
2021
|
+
const SNAPSHOT_INTERVAL_MS = 3e4;
|
|
1679
2022
|
/** Required services: the web server, the persisted session log store, and user settings. */
|
|
1680
2023
|
const inject = [
|
|
1681
2024
|
"webServer",
|
|
@@ -1759,15 +2102,160 @@ async function resolveSubscriptionKeys(settings, credentials) {
|
|
|
1759
2102
|
function apply(ctx, config = {}) {
|
|
1760
2103
|
const aggregator = createUsageAggregator(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } });
|
|
1761
2104
|
const cwd = process.cwd();
|
|
2105
|
+
const snapshotPath = join(homedir(), ".dsh/.dsh-usage-stats.json");
|
|
1762
2106
|
const candidates = [
|
|
1763
2107
|
config.statsPath,
|
|
1764
2108
|
process.env.DSH_USAGE_STATS,
|
|
1765
2109
|
join(cwd, ".dsh-usage-stats.json"),
|
|
1766
|
-
|
|
2110
|
+
snapshotPath
|
|
1767
2111
|
].filter((path) => typeof path === "string" && path.length > 0);
|
|
2112
|
+
let lastSnapshotAt = 0;
|
|
2113
|
+
const persistSnapshot = (doc) => {
|
|
2114
|
+
const now = Date.now();
|
|
2115
|
+
if (now - lastSnapshotAt < SNAPSHOT_INTERVAL_MS) return;
|
|
2116
|
+
lastSnapshotAt = now;
|
|
2117
|
+
writeFileAtomic(snapshotPath, JSON.stringify({
|
|
2118
|
+
...doc,
|
|
2119
|
+
_writer: {
|
|
2120
|
+
pid: process.pid,
|
|
2121
|
+
at: now
|
|
2122
|
+
}
|
|
2123
|
+
}), {
|
|
2124
|
+
mode: 384,
|
|
2125
|
+
dirMode: 448
|
|
2126
|
+
}).catch(() => {});
|
|
2127
|
+
};
|
|
2128
|
+
(async () => {
|
|
2129
|
+
try {
|
|
2130
|
+
const text = await readFile(snapshotPath, "utf8");
|
|
2131
|
+
const writer = JSON.parse(text)._writer;
|
|
2132
|
+
if (writer?.pid !== void 0 && writer.pid !== process.pid && writer.at !== void 0 && Date.now() - writer.at < 6e4) console.warn(`[usage-billing] 检测到另一实例(pid ${writer.pid})正在提供用量统计,双实例可能导致提醒重复。`);
|
|
2133
|
+
} catch {}
|
|
2134
|
+
})();
|
|
2135
|
+
ctx.inject(["tools"], (toolsCtx) => {
|
|
2136
|
+
toolsCtx.tools.register(defineTool({
|
|
2137
|
+
name: "usage_stats",
|
|
2138
|
+
description: "查询本机 DeepSeek Harness 的模型用量与估算费用(人民币,按官方目录价估算,非账单)。range 取值:today=今天,month=本月,session=当前会话,all=累计。",
|
|
2139
|
+
parameters: { range: {
|
|
2140
|
+
type: "string",
|
|
2141
|
+
enum: [
|
|
2142
|
+
"today",
|
|
2143
|
+
"month",
|
|
2144
|
+
"session",
|
|
2145
|
+
"all"
|
|
2146
|
+
],
|
|
2147
|
+
required: true,
|
|
2148
|
+
description: "统计范围:today / month / session / all"
|
|
2149
|
+
} },
|
|
2150
|
+
output: {
|
|
2151
|
+
schema: {
|
|
2152
|
+
type: "object",
|
|
2153
|
+
additionalProperties: false,
|
|
2154
|
+
properties: {
|
|
2155
|
+
range: {
|
|
2156
|
+
type: "string",
|
|
2157
|
+
required: true
|
|
2158
|
+
},
|
|
2159
|
+
cost: {
|
|
2160
|
+
type: "number",
|
|
2161
|
+
required: true,
|
|
2162
|
+
description: "估算费用(人民币元)"
|
|
2163
|
+
},
|
|
2164
|
+
calls: {
|
|
2165
|
+
type: "number",
|
|
2166
|
+
required: true
|
|
2167
|
+
},
|
|
2168
|
+
input: {
|
|
2169
|
+
type: "number",
|
|
2170
|
+
required: true,
|
|
2171
|
+
description: "输入 tokens"
|
|
2172
|
+
},
|
|
2173
|
+
output: {
|
|
2174
|
+
type: "number",
|
|
2175
|
+
required: true,
|
|
2176
|
+
description: "输出 tokens"
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
},
|
|
2180
|
+
render: (_args, value) => [{
|
|
2181
|
+
type: "text",
|
|
2182
|
+
text: `用量(${value.range}):估算费用 ${formatMoney(value.cost)},调用 ${value.calls} 次,输入 ${formatTokens(value.input)} tokens,输出 ${formatTokens(value.output)} tokens`
|
|
2183
|
+
}]
|
|
2184
|
+
},
|
|
2185
|
+
async execute(args, exec) {
|
|
2186
|
+
const stats = await aggregator.aggregate();
|
|
2187
|
+
const zero = {
|
|
2188
|
+
range: args.range,
|
|
2189
|
+
cost: 0,
|
|
2190
|
+
calls: 0,
|
|
2191
|
+
input: 0,
|
|
2192
|
+
output: 0
|
|
2193
|
+
};
|
|
2194
|
+
if (args.range === "all") return {
|
|
2195
|
+
range: args.range,
|
|
2196
|
+
cost: stats.total.cost,
|
|
2197
|
+
calls: stats.total.calls,
|
|
2198
|
+
input: stats.total.input,
|
|
2199
|
+
output: stats.total.output
|
|
2200
|
+
};
|
|
2201
|
+
if (args.range === "today") {
|
|
2202
|
+
const day = stats.byDay[dayStamp(Date.now())];
|
|
2203
|
+
return day === void 0 ? zero : {
|
|
2204
|
+
range: args.range,
|
|
2205
|
+
cost: day.cost,
|
|
2206
|
+
calls: day.calls,
|
|
2207
|
+
input: day.input,
|
|
2208
|
+
output: day.output
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
if (args.range === "month") {
|
|
2212
|
+
const prefix = dayStamp(Date.now()).slice(0, 7);
|
|
2213
|
+
let cost = 0;
|
|
2214
|
+
let calls = 0;
|
|
2215
|
+
let input = 0;
|
|
2216
|
+
let output = 0;
|
|
2217
|
+
for (const [date, day] of Object.entries(stats.byDay)) {
|
|
2218
|
+
if (!date.startsWith(prefix)) continue;
|
|
2219
|
+
cost += day.cost;
|
|
2220
|
+
calls += day.calls;
|
|
2221
|
+
input += day.input;
|
|
2222
|
+
output += day.output;
|
|
2223
|
+
}
|
|
2224
|
+
return {
|
|
2225
|
+
range: args.range,
|
|
2226
|
+
cost,
|
|
2227
|
+
calls,
|
|
2228
|
+
input,
|
|
2229
|
+
output
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
const sessionId = exec.agent?.id;
|
|
2233
|
+
if (sessionId === void 0) throw new Error("usage_stats 的 session 范围需要 agent 会话上下文");
|
|
2234
|
+
let cost = 0;
|
|
2235
|
+
let calls = 0;
|
|
2236
|
+
let input = 0;
|
|
2237
|
+
let output = 0;
|
|
2238
|
+
for (const turn of stats.byTurn ?? []) {
|
|
2239
|
+
if (turn.sessionId !== String(sessionId)) continue;
|
|
2240
|
+
cost += turn.cost;
|
|
2241
|
+
calls += 1;
|
|
2242
|
+
input += turn.input;
|
|
2243
|
+
output += turn.output;
|
|
2244
|
+
}
|
|
2245
|
+
return {
|
|
2246
|
+
range: args.range,
|
|
2247
|
+
cost,
|
|
2248
|
+
calls,
|
|
2249
|
+
input,
|
|
2250
|
+
output
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
}));
|
|
2254
|
+
});
|
|
1768
2255
|
let live = { source: "builtin" };
|
|
1769
2256
|
const refreshPricing = async () => {
|
|
1770
2257
|
live = await fetchLivePricing();
|
|
2258
|
+
applyLivePricing(live);
|
|
1771
2259
|
};
|
|
1772
2260
|
refreshPricing();
|
|
1773
2261
|
ctx.effect(() => {
|
|
@@ -1794,7 +2282,8 @@ function apply(ctx, config = {}) {
|
|
|
1794
2282
|
const providers = { ...await readPiAiProviders(ctx.settings) };
|
|
1795
2283
|
if (providers["deepseek"] === void 0) providers["deepseek"] = { apiKeyEnv: config.balanceApiKeyEnv ?? DEFAULT_BALANCE_API_KEY_ENV };
|
|
1796
2284
|
const balances = await queryBalances(ctx, providers);
|
|
1797
|
-
|
|
2285
|
+
const custom = await queryCustomBalances(ctx, config.customBalances ?? []);
|
|
2286
|
+
res.end(JSON.stringify({ balances: [...balances, ...custom] }));
|
|
1798
2287
|
}
|
|
1799
2288
|
}), "usage-billing: balance route");
|
|
1800
2289
|
let quotaCache = {
|
|
@@ -1838,10 +2327,12 @@ function apply(ctx, config = {}) {
|
|
|
1838
2327
|
...config.monthlyBudget === void 0 ? {} : { budget: config.monthlyBudget },
|
|
1839
2328
|
...config.lowBalanceThreshold === void 0 ? {} : { lowBalanceThreshold: config.lowBalanceThreshold }
|
|
1840
2329
|
};
|
|
1841
|
-
|
|
2330
|
+
const payload = Object.keys(injected).length === 0 ? stats : {
|
|
1842
2331
|
...stats,
|
|
1843
2332
|
...injected
|
|
1844
|
-
}
|
|
2333
|
+
};
|
|
2334
|
+
persistSnapshot(payload);
|
|
2335
|
+
res.end(JSON.stringify(payload));
|
|
1845
2336
|
return;
|
|
1846
2337
|
} catch {}
|
|
1847
2338
|
for (const candidate of candidates) try {
|