@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/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
- return MODEL_CATALOG.find((entry) => entry.key === resolved) ?? (() => {
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 && MODEL_CATALOG.some((entry) => entry.key === key)) acc.cost += computeCostAt(modelOf(key), {
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 && MODEL_CATALOG.some((entry) => entry.key === modelKey)) state.cost += computeCostAt(modelOf(modelKey), {
774
- input: (usage.cacheReadTokens ?? 0) + usage.inputTokens + (usage.cacheWriteTokens ?? 0),
775
- cacheHit: usage.cacheReadTokens ?? 0,
776
- cacheMiss: usage.inputTokens + (usage.cacheWriteTokens ?? 0),
777
- output: usage.outputTokens
778
- }, event.time);
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([fetchRate(), fetchRouterModels()]);
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
- if (rate === void 0 && prices === void 0) return { source: "builtin" };
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 (limit === null || remaining === null || limit <= 0) return null;
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
- join(homedir(), ".dsh/.dsh-usage-stats.json")
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
- res.end(JSON.stringify({ balances }));
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
- res.end(JSON.stringify(Object.keys(injected).length === 0 ? stats : {
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 {