@wannanbigpig/dsh-usage-stats 0.1.2 → 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
@@ -133,7 +133,7 @@ DEEPSEEK_API_KEY: sk-your-key-here
133
133
 
134
134
  ### 用量提醒与限额(设置 → 用量与计费)
135
135
 
136
- 「设置 → 用量与计费」页面承载限额配置(已从查询弹窗迁出,弹窗保持只读)。可**按 Key(或全局)**配置:
136
+ 「设置 → 用量与计费」页面承载限额配置(已从查询弹窗迁出,弹窗保持只读)。可**按 Key(或全局)**配置;**仅配置一个 API Key 时,「目标 API Key」选择器自动隐藏**,直接配置全局规则即可(单 Key 下全局规则就是生效规则):
137
137
 
138
138
  - **启用限额**:开关。
139
139
  - **每日消费限额**(CNY):今日估算消费达到限额 × `alertPercent`(默认 80%)→ 黄色预警;达到限额 × `criticalPercent`(默认 90%)→ 红色已超限(仅提醒与告警,不拦截);两个比例都可在设置页调整。
@@ -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
  }
@@ -1095,13 +1095,18 @@ window.__ModuleLoader__.load({
1095
1095
  const [limits, setLimits] = react.useState(null);
1096
1096
  const limitsRef = react.useRef(null);
1097
1097
  const [statusMap, setStatusMap] = react.useState({});
1098
+ const isSingleKey = (keys || []).length <= 1;
1098
1099
  const [activeKey, setActiveKey] = react.useState(() => {
1100
+ if (isSingleKey) return "__global__";
1099
1101
  const stored = safeGetStorage("dsh_usage_limits_active_key");
1100
1102
  if (stored !== null && stored !== "") return stored;
1101
1103
  return selectedKey ?? "__global__";
1102
1104
  });
1103
1105
  const [saving, setSaving] = react.useState(false);
1104
1106
  const [error, setError] = react.useState(null);
1107
+ // 限额文档未加载完成前禁用表单:GET 失败时 limits 恒为 null,若仍可保存会
1108
+ // 以空文档为基底 POST,整体覆盖并清空服务器上的全部限额配置。
1109
+ const [loaded, setLoaded] = react.useState(false);
1105
1110
  const pendingRef = react.useRef(null);
1106
1111
  const debounceTimerRef = react.useRef(null);
1107
1112
  const savingRef = react.useRef(false);
@@ -1122,6 +1127,7 @@ window.__ModuleLoader__.load({
1122
1127
  limitsRef.current = payload.limits;
1123
1128
  setLimits(payload.limits);
1124
1129
  setStatusMap(payload.status || {});
1130
+ setLoaded(true);
1125
1131
  }
1126
1132
  } catch (err) {
1127
1133
  setError(err instanceof Error ? err.message : String(err));
@@ -1134,6 +1140,10 @@ window.__ModuleLoader__.load({
1134
1140
 
1135
1141
  // Sync activeKey when selectedKey becomes available (if not explicitly chosen in storage)
1136
1142
  react.useEffect(() => {
1143
+ if (isSingleKey) {
1144
+ setActiveKey("__global__");
1145
+ return;
1146
+ }
1137
1147
  if (selectedKey !== null && selectedKey !== undefined && selectedKey !== "") {
1138
1148
  const stored = safeGetStorage("dsh_usage_limits_active_key");
1139
1149
  if (stored) {
@@ -1142,7 +1152,7 @@ window.__ModuleLoader__.load({
1142
1152
  }
1143
1153
  setActiveKey(selectedKey);
1144
1154
  }
1145
- }, [selectedKey]);
1155
+ }, [selectedKey, isSingleKey]);
1146
1156
 
1147
1157
  // Update form fields when activeKey or limits payload change
1148
1158
  react.useEffect(() => {
@@ -1160,6 +1170,7 @@ window.__ModuleLoader__.load({
1160
1170
  }, [activeKey, limits]);
1161
1171
 
1162
1172
  const handleKeySelect = (newKey) => {
1173
+ if (isSingleKey) return;
1163
1174
  // Do not strand an unsaved batch on the previous key's form.
1164
1175
  if (pendingRef.current !== null && !savingRef.current) flushSave();
1165
1176
  setActiveKey(newKey);
@@ -1196,6 +1207,7 @@ window.__ModuleLoader__.load({
1196
1207
  };
1197
1208
 
1198
1209
  const handleSave = async (overrideRule, immediate = false) => {
1210
+ if (!loaded) return;
1199
1211
  // Debounce + latest-value compensation: every change replaces the
1200
1212
  // pending batch with a full rule snapshot, so the flush always sends
1201
1213
  // the newest values even while a previous request is still in flight.
@@ -1304,10 +1316,9 @@ window.__ModuleLoader__.load({
1304
1316
  })
1305
1317
  ]
1306
1318
  }),
1307
- react_jsx_runtime.jsx("p", { className: S.note, children: translate("limits.desc") }),
1308
1319
  error && react_jsx_runtime.jsx("div", { className: S.error, children: error }),
1309
- // Key selection
1310
- react_jsx_runtime.jsxs("label", {
1320
+ // Key selection — hidden when only one key is configured (the global rule IS the key rule).
1321
+ keys.length > 1 && react_jsx_runtime.jsxs("label", {
1311
1322
  className: S.pickerRow,
1312
1323
  children: [
1313
1324
  react_jsx_runtime.jsxs("span", {
@@ -1369,6 +1380,7 @@ window.__ModuleLoader__.load({
1369
1380
  react_jsx_runtime.jsx("input", {
1370
1381
  type: "checkbox",
1371
1382
  checked: enabled,
1383
+ disabled: !loaded,
1372
1384
  onChange: (e) => {
1373
1385
  const nextVal = e.target.checked;
1374
1386
  setEnabled(nextVal);
@@ -1390,6 +1402,7 @@ window.__ModuleLoader__.load({
1390
1402
  react_jsx_runtime.jsx("input", {
1391
1403
  type: "checkbox",
1392
1404
  checked: stopOnExceed,
1405
+ disabled: !loaded,
1393
1406
  onChange: (e) => {
1394
1407
  const nextVal = e.target.checked;
1395
1408
  setStopOnExceed(nextVal);
@@ -2122,14 +2135,20 @@ window.__ModuleLoader__.load({
2122
2135
  className: S.sidebarText,
2123
2136
  children: [
2124
2137
  react_jsx_runtime.jsx("span", { className: S.sidebarLabel, children: t("panel.badge") }),
2125
- react_jsx_runtime.jsxs("span", {
2138
+ react_jsx_runtime.jsxs("span", {
2126
2139
  className: S.sidebarSummary,
2127
2140
  title: summaryText,
2128
2141
  children: [
2129
- summary.balanceStatus !== "muted" ? react_jsx_runtime.jsx("span", { className: S.statusDot, "data-tone": summary.balanceStatus }) : null,
2130
- summary.todayStatus !== "muted" ? react_jsx_runtime.jsx("span", { className: S.statusDot, "data-tone": summary.todayStatus }) : null,
2131
- summaryText
2132
- ]
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
+ ]
2133
2152
  })
2134
2153
  ]
2135
2154
  })
@@ -2202,7 +2221,6 @@ window.__ModuleLoader__.load({
2202
2221
  "chart.inputWithCache": "输入(含缓存)",
2203
2222
  "chart.peakNote": "高峰:北京时间 09-12 / 14-18,费用×2",
2204
2223
  "limits.title": "用量提醒与限额设置",
2205
- "limits.desc": "按指定 API Key 或全局设置每日消费限额与预警比例;硬停止默认关闭,仅在主动开启后阻止新调用。",
2206
2224
  "limits.apiKey": "目标 API Key",
2207
2225
  "limits.global": "全局默认 (全部 Key)",
2208
2226
  "limits.enable": "启用用量提醒",
@@ -2226,6 +2244,7 @@ window.__ModuleLoader__.load({
2226
2244
  "limits.status.blocked": "已停止新调用",
2227
2245
  "limits.status.stale": "余额数据已过期",
2228
2246
  "limits.status.unavailable": "余额暂不可用",
2247
+ "limits.status.unpriced": "费用不可靠(含未定价模型)",
2229
2248
  "limits.status.unlimited": "未限制",
2230
2249
  "limits.progress": "今日已消费 {spent} / 限额 {limit} ({percent}%)",
2231
2250
  "limits.save": "保存设置",
@@ -2302,7 +2321,6 @@ window.__ModuleLoader__.load({
2302
2321
  "chart.inputWithCache": "Input (incl. cache)",
2303
2322
  "chart.peakNote": "Peak: Beijing 09-12 / 14-18, ×2 price",
2304
2323
  "limits.title": "Usage Alerts & Quota Limits",
2305
- "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.",
2306
2324
  "limits.apiKey": "Target API Key",
2307
2325
  "limits.global": "Global Default (All Keys)",
2308
2326
  "limits.enable": "Enable Usage Alerts",
@@ -2326,6 +2344,7 @@ window.__ModuleLoader__.load({
2326
2344
  "limits.status.blocked": "New Calls Blocked",
2327
2345
  "limits.status.stale": "Balance Data Stale",
2328
2346
  "limits.status.unavailable": "Balance Unavailable",
2347
+ "limits.status.unpriced": "Cost Unreliable (Unpriced Models)",
2329
2348
  "limits.status.unlimited": "No Limit",
2330
2349
  "limits.progress": "Spent today {spent} / Limit {limit} ({percent}%)",
2331
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.2",
4
+ "version": "0.1.4",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/wannanbigpig/dsh-usage-stats.git"