@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 +2 -2
- package/lib/client.js +31 -12
- package/lib/index.js +68 -13
- package/package.json +1 -1
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
|
|
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
|
-
|
|
2138
|
+
react_jsx_runtime.jsxs("span", {
|
|
2126
2139
|
className: S.sidebarSummary,
|
|
2127
2140
|
title: summaryText,
|
|
2128
2141
|
children: [
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
1053
|
-
const
|
|
1054
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
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.
|
|
4
|
+
"version": "0.1.4",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/wannanbigpig/dsh-usage-stats.git"
|