@wannanbigpig/dsh-usage-stats 0.1.3 → 0.1.4

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 CHANGED
@@ -143,7 +143,7 @@ DEEPSEEK_API_KEY: sk-your-key-here
143
143
 
144
144
  限额保存在 `~/.dsh/storages/usage-limits.json`,当前 schema 为 v2。旧 v1 文件会安全迁移:保留提醒规则,但不会自动继承旧 `stopOnExceed` / `minBalance` 为硬停止;用户需在设置页重新确认开启。v2 会拒绝未知配置字段。**规则解析采用全局兜底**:某个 Key 未设置数值(或仅有空壳规则)时,沿用全局限额;Key 设置了数值则覆盖全局、未设置的字段继续继承全局,因此全局限额始终是底线,不会被空壳 Key 规则静默绕过。拦截采用 **fail-open** 策略:限额检查本身出错时放行调用,绝不因插件故障阻塞模型。
145
145
 
146
- 状态统一为 `normal / warning / exceeded / blocked / stale / unavailable`。侧栏状态点与设置页读取同一个 `/limits` 状态源;告警只在状态跨越或冷却到期时触发,恢复正常时生成一次恢复事件,避免每次轮询或模型请求重复提醒。
146
+ 状态统一为 `normal / warning / exceeded / blocked / stale / unavailable / unpriced`(`unpriced`:当日用量含未定价模型,消费金额不可靠,日限额不参与拦截且 fail-open;硬停止消息会点名真实触发原因——达到 100% 每日限额或余额跌破保障线,而非 90% 预警文案)。侧栏状态点与设置页读取同一个 `/limits` 状态源;告警只在状态跨越或冷却到期时触发,恢复正常时生成一次恢复事件,避免每次轮询或模型请求重复提醒。
147
147
 
148
148
  默认单价(CNY / 1M tokens,严格对应 DeepSeek 官方中文价格页,2026-08):
149
149
 
package/lib/client.js CHANGED
@@ -673,7 +673,7 @@ window.__ModuleLoader__.load({
673
673
  }
674
674
 
675
675
  function limitStatusLabelKey(status) {
676
- return ["normal", "warning", "exceeded", "blocked", "stale", "unavailable"].includes(status)
676
+ return ["normal", "warning", "exceeded", "blocked", "stale", "unavailable", "unpriced"].includes(status)
677
677
  ? `limits.status.${status}`
678
678
  : "limits.status.normal";
679
679
  }
@@ -1104,6 +1104,9 @@ window.__ModuleLoader__.load({
1104
1104
  });
1105
1105
  const [saving, setSaving] = react.useState(false);
1106
1106
  const [error, setError] = react.useState(null);
1107
+ // 限额文档未加载完成前禁用表单:GET 失败时 limits 恒为 null,若仍可保存会
1108
+ // 以空文档为基底 POST,整体覆盖并清空服务器上的全部限额配置。
1109
+ const [loaded, setLoaded] = react.useState(false);
1107
1110
  const pendingRef = react.useRef(null);
1108
1111
  const debounceTimerRef = react.useRef(null);
1109
1112
  const savingRef = react.useRef(false);
@@ -1124,6 +1127,7 @@ window.__ModuleLoader__.load({
1124
1127
  limitsRef.current = payload.limits;
1125
1128
  setLimits(payload.limits);
1126
1129
  setStatusMap(payload.status || {});
1130
+ setLoaded(true);
1127
1131
  }
1128
1132
  } catch (err) {
1129
1133
  setError(err instanceof Error ? err.message : String(err));
@@ -1203,6 +1207,7 @@ window.__ModuleLoader__.load({
1203
1207
  };
1204
1208
 
1205
1209
  const handleSave = async (overrideRule, immediate = false) => {
1210
+ if (!loaded) return;
1206
1211
  // Debounce + latest-value compensation: every change replaces the
1207
1212
  // pending batch with a full rule snapshot, so the flush always sends
1208
1213
  // the newest values even while a previous request is still in flight.
@@ -1311,7 +1316,6 @@ window.__ModuleLoader__.load({
1311
1316
  })
1312
1317
  ]
1313
1318
  }),
1314
- react_jsx_runtime.jsx("p", { className: S.note, children: translate("limits.desc") }),
1315
1319
  error && react_jsx_runtime.jsx("div", { className: S.error, children: error }),
1316
1320
  // Key selection — hidden when only one key is configured (the global rule IS the key rule).
1317
1321
  keys.length > 1 && react_jsx_runtime.jsxs("label", {
@@ -1376,6 +1380,7 @@ window.__ModuleLoader__.load({
1376
1380
  react_jsx_runtime.jsx("input", {
1377
1381
  type: "checkbox",
1378
1382
  checked: enabled,
1383
+ disabled: !loaded,
1379
1384
  onChange: (e) => {
1380
1385
  const nextVal = e.target.checked;
1381
1386
  setEnabled(nextVal);
@@ -1397,6 +1402,7 @@ window.__ModuleLoader__.load({
1397
1402
  react_jsx_runtime.jsx("input", {
1398
1403
  type: "checkbox",
1399
1404
  checked: stopOnExceed,
1405
+ disabled: !loaded,
1400
1406
  onChange: (e) => {
1401
1407
  const nextVal = e.target.checked;
1402
1408
  setStopOnExceed(nextVal);
@@ -2129,14 +2135,20 @@ window.__ModuleLoader__.load({
2129
2135
  className: S.sidebarText,
2130
2136
  children: [
2131
2137
  react_jsx_runtime.jsx("span", { className: S.sidebarLabel, children: t("panel.badge") }),
2132
- react_jsx_runtime.jsxs("span", {
2138
+ react_jsx_runtime.jsxs("span", {
2133
2139
  className: S.sidebarSummary,
2134
2140
  title: summaryText,
2135
2141
  children: [
2136
- summary.balanceStatus !== "muted" ? react_jsx_runtime.jsx("span", { className: S.statusDot, "data-tone": summary.balanceStatus }) : null,
2137
- summary.todayStatus !== "muted" ? react_jsx_runtime.jsx("span", { className: S.statusDot, "data-tone": summary.todayStatus }) : null,
2138
- summaryText
2139
- ]
2142
+ react_jsx_runtime.jsxs("span", { className: S.statusItem, children: [
2143
+ summary.balanceStatus !== "muted" ? react_jsx_runtime.jsx("span", { className: S.statusDot, "data-tone": summary.balanceStatus }) : null,
2144
+ `余额 ${summary.balance}`
2145
+ ] }),
2146
+ " · ",
2147
+ react_jsx_runtime.jsxs("span", { className: S.statusItem, children: [
2148
+ summary.todayStatus !== "muted" ? react_jsx_runtime.jsx("span", { className: S.statusDot, "data-tone": summary.todayStatus }) : null,
2149
+ `今日 ${summary.today}`
2150
+ ] })
2151
+ ]
2140
2152
  })
2141
2153
  ]
2142
2154
  })
@@ -2209,7 +2221,6 @@ window.__ModuleLoader__.load({
2209
2221
  "chart.inputWithCache": "输入(含缓存)",
2210
2222
  "chart.peakNote": "高峰:北京时间 09-12 / 14-18,费用×2",
2211
2223
  "limits.title": "用量提醒与限额设置",
2212
- "limits.desc": "按指定 API Key 或全局设置每日消费限额与预警比例;硬停止默认关闭,仅在主动开启后阻止新调用。",
2213
2224
  "limits.apiKey": "目标 API Key",
2214
2225
  "limits.global": "全局默认 (全部 Key)",
2215
2226
  "limits.enable": "启用用量提醒",
@@ -2233,6 +2244,7 @@ window.__ModuleLoader__.load({
2233
2244
  "limits.status.blocked": "已停止新调用",
2234
2245
  "limits.status.stale": "余额数据已过期",
2235
2246
  "limits.status.unavailable": "余额暂不可用",
2247
+ "limits.status.unpriced": "费用不可靠(含未定价模型)",
2236
2248
  "limits.status.unlimited": "未限制",
2237
2249
  "limits.progress": "今日已消费 {spent} / 限额 {limit} ({percent}%)",
2238
2250
  "limits.save": "保存设置",
@@ -2309,7 +2321,6 @@ window.__ModuleLoader__.load({
2309
2321
  "chart.inputWithCache": "Input (incl. cache)",
2310
2322
  "chart.peakNote": "Peak: Beijing 09-12 / 14-18, ×2 price",
2311
2323
  "limits.title": "Usage Alerts & Quota Limits",
2312
- "limits.desc": "Configure daily spend limits and alert thresholds per API key or globally. Hard stop is off by default and only blocks new calls after you opt in.",
2313
2324
  "limits.apiKey": "Target API Key",
2314
2325
  "limits.global": "Global Default (All Keys)",
2315
2326
  "limits.enable": "Enable Usage Alerts",
@@ -2333,6 +2344,7 @@ window.__ModuleLoader__.load({
2333
2344
  "limits.status.blocked": "New Calls Blocked",
2334
2345
  "limits.status.stale": "Balance Data Stale",
2335
2346
  "limits.status.unavailable": "Balance Unavailable",
2347
+ "limits.status.unpriced": "Cost Unreliable (Unpriced Models)",
2336
2348
  "limits.status.unlimited": "No Limit",
2337
2349
  "limits.progress": "Spent today {spent} / Limit {limit} ({percent}%)",
2338
2350
  "limits.save": "Save Settings",
package/lib/index.js CHANGED
@@ -361,6 +361,10 @@ function parseLedger(raw) {
361
361
  appendLedger(ledger, {
362
362
  id: entry.id,
363
363
  occurredAt: entry.occurredAt,
364
+ // completedAt 是账本按完成时间归小时/判峰谷的基准;缺省回退发起时间
365
+ // (与 ledger.js 的 attribution 契约一致),否则 loadCache 按 completedAt
366
+ // 过滤会把账本在每次重启后整体清空。
367
+ completedAt: entry.completedAt ?? entry.occurredAt,
364
368
  provider: entry.provider,
365
369
  model: entry.model,
366
370
  usage: entry.usage,
@@ -524,7 +528,7 @@ async function saveCache(ctx, cache) {
524
528
  }
525
529
  }
526
530
 
527
- /** Single-flight guard: concurrent requests share one aggregation run. */
531
+ /** Single-flight guard for READ-side aggregation: concurrent renders share one run. */
528
532
  function withLock(run) {
529
533
  if (inflight !== null) return inflight;
530
534
  inflight = run().finally(() => {
@@ -532,6 +536,20 @@ function withLock(run) {
532
536
  });
533
537
  return inflight;
534
538
  }
539
+
540
+ /**
541
+ * Serialized WRITE queue for ledger persistence: every call executes in
542
+ * order and none is dropped. A single-flight guard would silently discard
543
+ * the second concurrent completion (parallel tool calls are common) and
544
+ * undercount billing; sharing the read lock also made collectUsage return
545
+ * a ledger entry instead of the usage payload.
546
+ */
547
+ let writeChain = Promise.resolve();
548
+ function serializeWrite(run) {
549
+ const result = writeChain.then(run);
550
+ writeChain = result.then(() => void 0, () => void 0);
551
+ return result;
552
+ }
535
553
  //#endregion
536
554
 
537
555
  /**
@@ -894,7 +912,10 @@ function todayKeyLocal() {
894
912
  /** Build the per-key status map once, shared by evaluateStatus/evaluateAll. */
895
913
  async function evaluateStatuses({ ctx, config, balanceService, limits, usage, today, deps }) {
896
914
  const dayEntry = (usage.days ?? []).find((d) => d.date === today);
897
- const globalTodayCost = dayEntry?.cost ?? 0;
915
+ // 当日任一模型未定价(未知模型 id / 缺 model)→ day.cost null:消费金额
916
+ // 不可靠,绝不能静默当成 0 消费(否则日限额/硬停止整日失效)。
917
+ const costReliable = dayEntry === void 0 || dayEntry.cost !== null;
918
+ const globalTodayCost = dayEntry === void 0 ? 0 : (dayEntry.cost ?? 0);
898
919
  const { perKey } = todayCostPerKey(usage.days, today, config);
899
920
  const keys = [...new Set([...config.keys, ...Object.keys(limits.keys)])];
900
921
  // First pass: collect the refs whose cached balance is stale or missing.
@@ -928,6 +949,7 @@ async function evaluateStatuses({ ctx, config, balanceService, limits, usage, to
928
949
  keyRef: ref,
929
950
  limits,
930
951
  todayCost: todayCostFor(ref, perKey, globalTodayCost, config),
952
+ todayCostReliable: costReliable,
931
953
  balance: account?.balance ?? null,
932
954
  balanceStatus: account?.status,
933
955
  balanceFetchedAt: account?.fetchedAt,
@@ -958,7 +980,9 @@ function resolveLimitRule(allLimits, keyRef) {
958
980
  // inherit the global for any field left unset (null), so the global stays
959
981
  // the floor and a key can only tighten — never silently opt out.
960
982
  return {
961
- enabled: keyRule.enabled,
983
+ // Key 只能收紧、不能静默退出:全局开启时,Key 的 enabled:false 不得
984
+ // 关掉整个限额(与 stopOnExceed 的 OR 语义一致)。
985
+ enabled: keyRule.enabled === true || global.enabled === true,
962
986
  period: keyRule.period ?? global.period ?? "daily",
963
987
  dailyCostLimit: keyRule.dailyCostLimit ?? global.dailyCostLimit ?? null,
964
988
  monthlyCostLimit: keyRule.monthlyCostLimit ?? global.monthlyCostLimit ?? null,
@@ -971,7 +995,7 @@ function resolveLimitRule(allLimits, keyRef) {
971
995
  };
972
996
  }
973
997
 
974
- function evaluateKeyQuota({ keyRef, limits, todayCost = 0, balance = null, balanceStatus, balanceFetchedAt, now = Date.now(), balanceMaxAgeMs = Infinity }) {
998
+ function evaluateKeyQuota({ keyRef, limits, todayCost = 0, todayCostReliable = true, balance = null, balanceStatus, balanceFetchedAt, now = Date.now(), balanceMaxAgeMs = Infinity }) {
975
999
  const allLimits = limits ?? defaultLimits();
976
1000
  const rule = resolveLimitRule(allLimits, keyRef);
977
1001
  const numericCost = Number(todayCost) || 0;
@@ -1028,8 +1052,9 @@ function evaluateKeyQuota({ keyRef, limits, todayCost = 0, balance = null, balan
1028
1052
  let reason = null;
1029
1053
  let message = "";
1030
1054
 
1031
- // Check daily cost limit
1032
- if (rule.dailyCostLimit !== null && rule.dailyCostLimit > 0) {
1055
+ // Check daily cost limit (skipped when today's cost is unreliable: any
1056
+ // unpriced model makes the amount unknowable — never treat it as zero).
1057
+ if (todayCostReliable !== false && rule.dailyCostLimit !== null && rule.dailyCostLimit > 0) {
1033
1058
  if (numericCost >= rule.dailyCostLimit * (rule.criticalPercent / 100)) {
1034
1059
  exceeded = true;
1035
1060
  reason = "daily_cost";
@@ -1046,15 +1071,33 @@ function evaluateKeyQuota({ keyRef, limits, todayCost = 0, balance = null, balan
1046
1071
  message = `余额 (${numericBalance.toFixed(2)}) 已低于警戒线 (${rule.minBalance.toFixed(2)})`;
1047
1072
  }
1048
1073
 
1074
+ const unpriced = todayCostReliable === false;
1075
+ if (unpriced && reason === null) {
1076
+ reason = "unpriced";
1077
+ message = "今日用量包含未定价模型,消费金额不可靠,日限额暂不参与拦截";
1078
+ }
1049
1079
  const spendStatus = rule.dailyCostLimit !== null && rule.dailyCostLimit > 0
1050
- ? (exceeded ? "exceeded" : (warning ? "warning" : "normal"))
1080
+ ? (exceeded ? "exceeded" : (warning ? "warning" : (unpriced ? "muted" : "normal")))
1051
1081
  : "muted";
1052
- const hardLimitReached = balanceExceeded || (rule.dailyCostLimit !== null && rule.dailyCostLimit > 0 && numericCost >= rule.dailyCostLimit);
1053
- const status = exceeded && rule.stopOnExceed && hardLimitReached
1054
- ? "blocked"
1082
+ const costLimitReached = rule.dailyCostLimit !== null && rule.dailyCostLimit > 0 && numericCost >= rule.dailyCostLimit;
1083
+ const hardLimitReached = balanceExceeded || costLimitReached;
1084
+ const blocked = exceeded && rule.stopOnExceed && hardLimitReached;
1085
+ // 硬停止消息必须点明真实触发原因(达到 100% 限额或余额跌破保障线),
1086
+ // 不能沿用 90% 预警文案——否则用户会误以为在 90% 就被拦截。
1087
+ if (blocked) {
1088
+ if (balanceExceeded) {
1089
+ reason = "min_balance";
1090
+ message = `余额 (${numericBalance.toFixed(2)}) 已低于最低余额保障线 (${rule.minBalance.toFixed(2)}),已停止新调用`;
1091
+ } else if (costLimitReached) {
1092
+ reason = "daily_cost";
1093
+ message = `今日消费 (${numericCost.toFixed(2)}) 已达到每日限额 (${rule.dailyCostLimit.toFixed(2)}),已停止新调用`;
1094
+ }
1095
+ }
1096
+ const status = blocked ? "blocked"
1055
1097
  : exceeded ? "exceeded"
1056
1098
  : warning ? "warning"
1057
- : stale ? "stale"
1099
+ : unpriced ? "unpriced"
1100
+ : stale ? "stale"
1058
1101
  : unavailable ? "unavailable"
1059
1102
  : balanceAlertStatus === "warning" || balanceAlertStatus === "exceeded" ? balanceAlertStatus : "normal";
1060
1103
  if (reason === null && stale) {
@@ -1147,7 +1190,7 @@ const DEFAULT_MAX_LEDGER_ENTRIES = 5000;
1147
1190
  * @param deps - optional { maxLedgerEntries }; the two-arg call stays valid.
1148
1191
  */
1149
1192
  async function recordLedgerEntry(ctx, entry, deps = {}) {
1150
- return withLock(async () => {
1193
+ return serializeWrite(async () => {
1151
1194
  const cache = await loadCache();
1152
1195
  appendLedger(cache.ledger, entry);
1153
1196
  const maxEntries = Number.isFinite(Number(deps?.maxLedgerEntries)) && Number(deps.maxLedgerEntries) > 0
@@ -1183,7 +1226,14 @@ function createLimitsService({ ctx, config, balanceService, deps = {} }) {
1183
1226
  }
1184
1227
 
1185
1228
  async function updateLimits(raw) {
1186
- const validated = validateLimits(raw);
1229
+ // 空/畸形 body(null、{}、无 global/keys)会把配置静默重置为默认值并
1230
+ // 持久化,清空全部限额规则——客户端总是在加载完成后发送完整文档,
1231
+ // 因此拒绝这类载荷是安全的。
1232
+ const body = raw === null || typeof raw !== "object" || Array.isArray(raw) ? null : raw;
1233
+ if (body === null || (typeof body.global !== "object" && typeof body.keys !== "object")) {
1234
+ throw new TypeError("limits payload must carry a global rule or a keys map");
1235
+ }
1236
+ const validated = validateLimits(body);
1187
1237
  await (deps.saveLimits ?? saveLimits)(ctx, validated);
1188
1238
  memoryLimits = validated;
1189
1239
  return validated;
@@ -1241,6 +1291,10 @@ async function handleLimits(ctx, config, limitsService, req, res) {
1241
1291
  }
1242
1292
  } catch (error) {
1243
1293
  ctx.logger.warn(`usage-stats: limits request failed: ${String(error)}`);
1294
+ if (error instanceof TypeError) {
1295
+ json(res, 400, { ok: false, error: "invalid-payload", message: error.message });
1296
+ return;
1297
+ }
1244
1298
  json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
1245
1299
  }
1246
1300
  }
@@ -1379,6 +1433,7 @@ export {
1379
1433
  roundCost,
1380
1434
  defaultLimitRule,
1381
1435
  defaultLimits,
1436
+ parseLedger,
1382
1437
  validateLimitRule,
1383
1438
  validateLimits,
1384
1439
  evaluateKeyQuota,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wannanbigpig/dsh-usage-stats",
3
3
  "description": "DeepSeek 官方余额、Token 用量、月历热图与离线 tokenizer,内置在 Harness 侧栏",
4
- "version": "0.1.3",
4
+ "version": "0.1.4",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/wannanbigpig/dsh-usage-stats.git"