@ds-hu/dsh-token-quota 2.4.1 → 2.5.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/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@
6
6
  更早版本(v2.0.5 及以前)的变更记录在 [README](README.md) 的「vX.Y.Z 更新」小节与
7
7
  [docs/](docs/) 下的对应执行记录中,本文件自 2.1.0 起逐版本记录。
8
8
 
9
+ ## [2.5.0] - 2026-11-03
10
+
11
+ ### 变更
12
+
13
+ - **今日行可切区间**:展开明细里的「Today」换成 `[Today ▾]` 选择器,可切「今天 / 近 7 天」;
14
+ token 数、归一比例与下面那条五分量分布条一起换成该区间的合计,图表固定 7 天不跟着变。
15
+ 右侧文字不再重复区间名,只留数量。
16
+ - **新增窗口估算**:同一行右侧给出各窗口(5h / 周 / 月)的 token 估算,可在「剩余」与「套餐总量」
17
+ 之间切换;悬停给出取样区间、分子分母与 ±1pp 区间。
18
+ - **估算按斜率折算**:每 1 个百分点的 token 量 = 取样内本地 token ÷ Δpp,剩余 = 斜率 × 剩余%,
19
+ 总量 = 斜率 × 100%。此前除以累计已用%:月窗口比本地留存长时,分子只拿得到最近几天的 token,
20
+ 3.23B 会推出 3.68B 的总量,而折线显示的是 3.23B 走了约 30pp。
21
+ - 窗口长度由供应商声明(`entries[].windowMs`),宿主把小时桶折成 `estimate{from,to,tokens,dPct}`
22
+ 随 `/state` 下发;取样左端取「窗口起点」与「本地留存边界」的较晚者,两端必须落在同一条折线段上。
23
+ - 文案净增 5 键(158 → 163),测试的文案预算同步上调。
24
+
25
+ ### 修复
26
+
27
+ - **图表柱值数字被折线压花**:柱值标签底下垫一层卡片底色,折线从数字背后穿过,数字不再被压线。
28
+ - **估算显示「—」却无从判断**:门槛从「已用 < 5%」改为「取样 Δpp < 2」——低电平不是噪声源,
29
+ 斜率才是;仍算不出来的窗口悬停提示「本地样本不足」,一个窗口都算不出来时整组不出现。
30
+
9
31
  ## [2.4.1] - 2026-11-01
10
32
 
11
33
  ### 变更
package/README.md CHANGED
@@ -2,11 +2,18 @@
2
2
 
3
3
  DeepSeek Harness 用量监控插件。查看当前会话对应供应商的周期限额、账户余额、报告费用和今日 Token 用量,支持 Windows 桌面端与 Web 界面。
4
4
 
5
- 当前版本:**v2.4.1**。要求 **DSH ≥ 0.1.7-rc.2**;真实桌面及 Web 验收环境为 **DSH 0.2.0-rc.2**。
5
+ 当前版本:**v2.5.0**。要求 **DSH ≥ 0.1.7-rc.2**;真实桌面及 Web 验收环境为 **DSH 0.2.0-rc.2**。
6
6
 
7
7
  Plugins 面板中显示为 **用量监控**(英文 **Usage monitor**):图标是根目录 `icon.svg`,标题与一句话说明来自 `locale/zh.json` 与 `locale/en.json` 的 `meta`。
8
8
 
9
- [GitHub 下载](https://github.com/shxtmaker/dsh-token-quota/releases/latest) · [问题反馈](https://github.com/shxtmaker/dsh-token-quota/issues)
9
+ [npm](https://www.npmjs.com/package/@ds-hu/dsh-token-quota) · [问题反馈](https://github.com/ds-hu/dsh-usage-monitor/issues)
10
+
11
+ ## v2.5.0 更新
12
+
13
+ - 展开行的「Today」换成选择器:可切「今天 / 近 7 天」,token 数、归一比例与五分量分布条一起换成该区间的合计;图表固定 7 天,不跟着变。
14
+ - 同一行右侧给出各窗口的 token 估算(5h / 周 / 月),可在「剩余」与「套餐总量」之间切换;悬停显示取样区间、分子分母与 ±1pp 区间。
15
+ - 估算按取样内的斜率折算(本地 token ÷ Δpp),不再除以累计已用%:月窗口比本地留存长时,旧算法只拿得到最近几天的 token,会把总量算成一半。
16
+ - 修复图表柱值数字被折线压花:数字底下垫一层卡片底色,折线从数字背后穿过。
10
17
 
11
18
  ## v2.4.1 更新
12
19
 
@@ -85,7 +92,7 @@ Plugins 面板中显示为 **用量监控**(英文 **Usage monitor**):图
85
92
 
86
93
  ## 安装
87
94
 
88
- 插件有两个来源:npm 包 **`@ds-hu/dsh-token-quota`**,或 Release 里的安装包 **`dsh-token-quota-2.4.0.tgz`**(源码包为 `dsh-token-quota-2.4.0-source.tar.gz`)。插件包不包含 DeepSeek Harness;首次安装可能需要联网下载依赖。
95
+ 插件从 npm 安装:**`@ds-hu/dsh-token-quota`**。离线部署时可以在源码目录 `npm pack` 打出 `ds-hu-dsh-token-quota-2.5.0.tgz`,用压缩包安装。插件包不包含 DeepSeek Harness;首次安装可能需要联网下载依赖。
89
96
 
90
97
  ### Windows 桌面端
91
98
 
@@ -93,15 +100,15 @@ Plugins 面板中显示为 **用量监控**(英文 **Usage monitor**):图
93
100
 
94
101
  ```powershell
95
102
  $desktopCli = 'D:\AI\DeepSeek- Harness\resources\runtime\cli\bin\dsh.cmd'
96
- $archivePath = (Resolve-Path './dsh-token-quota-2.4.0.tgz').Path
97
- & $desktopCli plugin --profile desktop add "file:$archivePath"
103
+ & $desktopCli plugin --profile desktop add @ds-hu/dsh-token-quota
98
104
  & $desktopCli plugin --profile desktop list --depth 0
99
105
  ```
100
106
 
101
- 插件已在 npm 上,用一条命令安装即可,不必先下载压缩包:
107
+ 离线安装时把包名换成压缩包路径:
102
108
 
103
109
  ```powershell
104
- & $desktopCli plugin --profile desktop add @ds-hu/dsh-token-quota
110
+ $archivePath = (Resolve-Path './ds-hu-dsh-token-quota-2.5.0.tgz').Path
111
+ & $desktopCli plugin --profile desktop add "file:$archivePath"
105
112
  ```
106
113
 
107
114
  重新打开桌面应用,展开左侧栏即可看到用量组件;也可从「设置 → 用量监控」进入。安装到 `web` profile 的插件不会自动出现在桌面端。桌面侧栏收起后组件随侧栏隐藏,展开后恢复。
@@ -117,19 +124,19 @@ dsh plugin --profile web add @ds-hu/dsh-token-quota
117
124
  dsh --profile web
118
125
  ```
119
126
 
120
- 从 Release 的压缩包安装:
127
+ 离线安装(先 `npm pack` 得到压缩包):
121
128
 
122
129
  Linux / macOS:
123
130
 
124
131
  ```bash
125
- dsh plugin --profile web add "file:$(pwd)/dsh-token-quota-2.4.0.tgz"
132
+ dsh plugin --profile web add "file:$(pwd)/ds-hu-dsh-token-quota-2.5.0.tgz"
126
133
  dsh --profile web
127
134
  ```
128
135
 
129
136
  Windows PowerShell:
130
137
 
131
138
  ```powershell
132
- $archivePath = (Resolve-Path './dsh-token-quota-2.4.0.tgz').Path
139
+ $archivePath = (Resolve-Path './ds-hu-dsh-token-quota-2.5.0.tgz').Path
133
140
  dsh plugin --profile web add "file:$archivePath"
134
141
  dsh --profile web
135
142
  ```
@@ -141,8 +148,8 @@ dsh --profile web
141
148
  ### 从源码安装
142
149
 
143
150
  ```bash
144
- git clone https://github.com/shxtmaker/dsh-token-quota.git
145
- cd dsh-token-quota
151
+ git clone https://github.com/ds-hu/dsh-usage-monitor.git
152
+ cd dsh-usage-monitor
146
153
  npm ci
147
154
  dsh plugin --profile web add "link:$(pwd)"
148
155
  dsh --profile web
@@ -262,14 +269,20 @@ npm pack
262
269
  真实宿主验收使用独立数据目录与 profile,不修改日常配置:
263
270
 
264
271
  ```powershell
265
- npm run test:desktop -- --app 'C:/path/DeepSeek Harness.exe' --package 'C:/path/dsh-token-quota-2.4.0.tgz' --output '.scratch/desktop-v2-check'
266
- node scripts/verify-web.mjs --app 'C:/path/DeepSeek Harness.exe' --package 'C:/path/dsh-token-quota-2.4.0.tgz' --output '.scratch/web-v2-check'
272
+ npm run test:desktop -- --app 'C:/path/DeepSeek Harness.exe' --package 'C:/path/ds-hu-dsh-token-quota-2.5.0.tgz' --output '.scratch/desktop-v2-check'
273
+ node scripts/verify-web.mjs --app 'C:/path/DeepSeek Harness.exe' --package 'C:/path/ds-hu-dsh-token-quota-2.5.0.tgz' --output '.scratch/web-v2-check'
267
274
  npm run test:lifecycle -- --app 'C:/path/DeepSeek Harness.exe' --output '.scratch/lifecycle-v2-check'
268
275
  ```
269
276
 
270
277
  输出目录需为未使用过的目录。供应商解析测试使用模拟响应;真实 Desktop 与 Web 验收检查安装、界面、配置持久化和重新扫描,不发送聊天消息或配置真实供应商密钥。
271
278
 
272
- [桌面兼容性说明](docs/windows-desktop-adaptation.md) · [单一面板重构记录](docs/single-panel-refactor-execution.md) · [ADR-0002](docs/adr/0002-single-monitor-surface.md) · [侧栏与设置实施记录](docs/sidebar-settings-iteration-plan.md)
279
+ [桌面兼容性说明](docs/windows-desktop-adaptation.md) · [单一面板重构记录](docs/single-panel-refactor-execution.md) · [ADR-0002](docs/adr/0002-single-monitor-surface.md) · [侧栏与设置实施记录](docs/sidebar-settings-iteration-plan.md) · [v2.5.0 发布记录](docs/v2.5.0-release-execution.md)
280
+
281
+ ## 来源与致谢
282
+
283
+ 本仓库是 [shxtmaker/dsh-token-quota](https://github.com/shxtmaker/dsh-token-quota) 的 fork,在 npm 上以
284
+ `@ds-hu/dsh-token-quota` 发布。插件 id、设置命名空间、数据目录与各家供应商的查询规则都沿用上游,
285
+ 行为变更逐条记在 [CHANGELOG](CHANGELOG.md)。上游没有 npm 包,需要它的原始版本请用上游的 Releases。
273
286
 
274
287
  ## 许可证
275
288
 
package/lib/client.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // 展示控制器统一请求与会话订阅;面板草稿独立于展示缓存与本地偏好。
3
3
  (function () {
4
4
  window.__ModuleLoader__.load({
5
- id: "dsh-token-quota",
5
+ id: "@ds-hu/dsh-token-quota",
6
6
  factory: (require) => {
7
7
  var module = { exports: {} };
8
8
  var exports = module.exports;
@@ -1174,6 +1174,16 @@
1174
1174
  const todayNumber = typeof sup.todayTokens === "number" ? sup.todayTokens : 0;
1175
1175
  const error = String(sup.error?.message || sup.error?.code || "");
1176
1176
  const detailId = `qm-detail-${sup.id}`;
1177
+ // 今日行与五分量分布条共用一份口径:切到「近 7 天」时两处一起换,图表固定 7 天不跟着变。
1178
+ // 估算口径(剩余/总量)只影响读数本身,不重新取数。
1179
+ const [range, setRange] = React.useState("today");
1180
+ const [estMode, setEstMode] = React.useState("left");
1181
+ const days = Array.isArray(sup.usageDays) ? sup.usageDays : [];
1182
+ const week = range === "7d";
1183
+ const scopedUsage = week ? sumUsageOf(days) : sup.todayUsage;
1184
+ const scopedTokens = week
1185
+ ? days.reduce((sum, day) => sum + (Number(day?.total) || 0), 0)
1186
+ : todayNumber;
1177
1187
  return h("article", { className: `qm-row${expanded ? " open" : ""}`, "data-supplier": sup.id },
1178
1188
  h("button", { type: "button", className: "qm-row-head", "data-qm-row": "", "aria-expanded": expanded, "aria-controls": detailId, onClick: onToggle },
1179
1189
  h("span", { className: `qm-dot ${stateClass(sup)}` }),
@@ -1188,8 +1198,11 @@
1188
1198
  error.length > 180 ? h("details", null, h("summary", null, t("errorDetails")), h("p", null, error)) : null) : null,
1189
1199
  sup.lastSuccessAt ? h("div", { className: "qm-data-time" }, `${t("lastSuccess")} ${new Date(sup.lastSuccessAt).toLocaleString()}`) : null,
1190
1200
  h(MetricTable, { supplier: sup, t }),
1191
- h(TodayLine, { sup, t }),
1192
- h(UsageSplit, { usage: sup.todayUsage, t }),
1201
+ h(TodayLine, {
1202
+ sup, t, tokens: scopedTokens, usage: scopedUsage,
1203
+ range, onRange: setRange, mode: estMode, onMode: setEstMode,
1204
+ }),
1205
+ h(UsageSplit, { usage: scopedUsage, t }),
1193
1206
  h(SeasonBlock, { season, split: sup.seasonSplit, t }),
1194
1207
  h("div", { className: "qm-row-actions" }, h("button", { type: "button", onClick: () => onEdit(sup.id) }, t("openPage"))),
1195
1208
  Array.isArray(sup.usageDays) && sup.usageDays.length
@@ -1210,14 +1223,77 @@
1210
1223
  h("td", { key: column.key, className: column.key === "used" ? `qm-tone ${row.tone}` : column.key === "value" ? "qm-num" : "" }, row[column.key] ?? "—"))))))));
1211
1224
  }
1212
1225
 
1213
- function TodayLine({ sup, t }) {
1214
- const total = fmtBig(typeof sup.todayTokens === "number" ? sup.todayTokens : 0);
1215
- const ratio = usageRatioOf(sup.todayUsage);
1226
+ /**
1227
+ * 今日行:区间选择器 + 数量 + 归一比例 + 窗口估算。
1228
+ * 「Today」两个字换成选择器后,同一行读作「哪个区间、多少 token、什么比例、各窗口还能用多少」。
1229
+ */
1230
+ function TodayLine({ sup, t, tokens, usage, range, onRange, mode, onMode }) {
1231
+ const total = fmtBig(tokens || 0);
1232
+ const ratio = usageRatioOf(usage);
1216
1233
  const detailText = ratio ? t("ratioAria", { hit: fmtBig(ratio.hitValue), miss: fmtBig(ratio.missValue), out: fmtBig(ratio.outValue) }) : "";
1234
+ // 区间已经由左边的选择器说清楚了,这里不再重复「Today / 近 7 天」,只留数量。
1235
+ const text = `${total} tokens`;
1217
1236
  return h("div", { className: "qm-today-line" },
1218
- h("span", { className: "qm-today-text" }, t("today", { n: total })),
1237
+ h("span", { className: "qm-range" },
1238
+ h("select", {
1239
+ value: range, "aria-label": t("rangeLabel"),
1240
+ onChange: (ev) => onRange(ev.target.value === "7d" ? "7d" : "today"),
1241
+ },
1242
+ h("option", { value: "today" }, t("todayLabel")),
1243
+ h("option", { value: "7d" }, t("range7d")))),
1244
+ h("span", { className: "qm-today-text" }, text),
1219
1245
  ratio ? h("span", { className: "qm-ratio", title: detailText },
1220
- h("b", { className: "r-hit" }, ratio.hit), " : ", h("b", { className: "r-miss" }, ratio.miss), " : ", h("b", { className: "r-out" }, ratio.out)) : null);
1246
+ h("b", { className: "r-hit" }, ratio.hit), " : ", h("b", { className: "r-miss" }, ratio.miss), " : ", h("b", { className: "r-out" }, ratio.out)) : null,
1247
+ h(EstimateGroup, { sup, t, mode, onMode }));
1248
+ }
1249
+
1250
+ /**
1251
+ * 窗口估算:同一段折线里「本地 token ÷ Δpp」得到每 1 个百分点值多少 token,
1252
+ * 再按当前电平换算成剩余(Left)或窗口总量(Total)。
1253
+ * 分母是 Δpp 而不是已用% —— 窗口比本地留存长时,分子只覆盖最近几天,
1254
+ * 除以累计百分比会把总量算成一半(3B 降 50pp 应得 6B,不是 3B ÷ 90%)。
1255
+ * title 里给出取样区间、分子分母与 ±1pp 的区间,好让人对着图核账。
1256
+ */
1257
+ function EstimateGroup({ sup, t, mode, onMode }) {
1258
+ const entries = (Array.isArray(sup.entries) ? sup.entries : [])
1259
+ .filter((entry) => entry && entry.kind === "win" && Number.isFinite(Number(entry.pct)));
1260
+ if (!entries.length) return null;
1261
+ const kind = mode === "left" ? t("estLeft") : t("estTotal");
1262
+ const chips = [];
1263
+ let estimable = 0;
1264
+ for (const entry of entries) {
1265
+ const label = entry.short || entry.name || entry.key;
1266
+ const pct = Math.min(100, Math.max(0, Number(entry.pct)));
1267
+ const est = entry.estimate;
1268
+ const tokens = Number(est?.tokens);
1269
+ const dPct = Number(est?.dPct);
1270
+ // 不到两个百分点(整数上报)就是噪声,宁可给「—」
1271
+ if (!(tokens > 0) || !(dPct >= 2)) {
1272
+ chips.push(h("span", { key: entry.key, className: "qm-est-q is-off", title: t("estNoData") }, `${label} —`));
1273
+ continue;
1274
+ }
1275
+ estimable++;
1276
+ // 剩余看还占多少个百分点,总量看满 100 个百分点
1277
+ const level = mode === "left" ? Math.max(0, 100 - pct) : 100;
1278
+ const at = (delta) => (tokens / delta) * level;
1279
+ const main = at(dPct);
1280
+ const spanMs = Math.max(0, Number(est.to) - Number(est.from));
1281
+ const span = spanMs >= 48 * 3600_000
1282
+ ? `${Math.round(spanMs / 86400_000)}d`
1283
+ : `${Math.max(1, Math.round(spanMs / 3600_000))}h`;
1284
+ const title = `${label} ${kind} ≈${fmtBig(Math.round(main))}\n`
1285
+ + `${span}: ${fmtBig(tokens)} ÷ Δ${dPct}pp\n`
1286
+ + `Δ${dPct}pp ±1pp → ${fmtBig(Math.round(at(dPct + 1)))}–${fmtBig(Math.round(at(dPct - 1)))}`;
1287
+ chips.push(h("span", { key: entry.key, className: "qm-est-q", title },
1288
+ `${label} `, h("b", null, `≈${fmtBig(Math.round(main))}`)));
1289
+ }
1290
+ // 一个窗口都算不出来时整组不出现:不拿一排「—」占掉今日行
1291
+ if (!estimable) return null;
1292
+ return h("span", { className: "qm-est" },
1293
+ h("span", { className: "qm-est-seg", role: "group" },
1294
+ h("button", { type: "button", "aria-pressed": mode === "left", onClick: () => onMode("left") }, t("estLeft")),
1295
+ h("button", { type: "button", "aria-pressed": mode === "total", onClick: () => onMode("total") }, t("estTotal"))),
1296
+ chips);
1221
1297
  }
1222
1298
 
1223
1299
  /** 五分量分布:条与图例一一对应;今日比例只取前三项,写缓存与未细分在这里出现。 */
@@ -1695,6 +1771,11 @@
1695
1771
  todayShort: "今日 {n}",
1696
1772
  statsWindow: "近 {n} 天",
1697
1773
  remainLabel: "剩余 {n}%",
1774
+ rangeLabel: "统计区间",
1775
+ range7d: "近 7 天",
1776
+ estLeft: "剩余",
1777
+ estTotal: "套餐总量",
1778
+ estNoData: "本地样本不足",
1698
1779
  ratioAria: "缓存命中 {hit} · 缓存未命中 {miss} · 输出 {out}",
1699
1780
  cacheHit: "缓存命中",
1700
1781
  cacheMiss: "缓存未命中",
@@ -1861,6 +1942,11 @@
1861
1942
  todayShort: "Today {n}",
1862
1943
  statsWindow: "Last {n} days",
1863
1944
  remainLabel: "{n}% left",
1945
+ rangeLabel: "Range",
1946
+ range7d: "Last 7 days",
1947
+ estLeft: "Left",
1948
+ estTotal: "Total",
1949
+ estNoData: "Not enough local samples",
1864
1950
  ratioAria: "Cache hits {hit} · cache misses {miss} · output {out}",
1865
1951
  cacheHit: "Cache hits",
1866
1952
  cacheMiss: "Cache misses",
@@ -1978,6 +2064,26 @@ div:has(> div[data-slot="sidebar.footer.action"] > [data-qm-linerow]){flex-wrap:
1978
2064
  .qm-today-line{display:flex;align-items:baseline;flex-wrap:wrap;gap:2px 10px;margin:8px 10px 6px;
1979
2065
  color:var(--dsw-alias-label-secondary,#a9b2c5);font-size:11px;font-variant-numeric:tabular-nums;}
1980
2066
  .qm-today-text{flex:none;}
2067
+ /* 区间选择器(今天 / 近 7 天):替代原来的「今日」二字,一次点击换口径 */
2068
+ .qm-card .qm-range{position:relative;display:inline-flex;align-items:center;flex:none;}
2069
+ .qm-card .qm-range select{font:inherit;font-size:11px;line-height:15px;padding:1px 16px 1px 6px;appearance:none;
2070
+ color:var(--dsw-alias-label-primary,#dbe2ee);background:var(--dsw-alias-bg-base,#0b0e13);
2071
+ border:1px solid var(--dsw-alias-border-l2,#363d4d);border-radius:6px;cursor:pointer;}
2072
+ .qm-range::after{content:"▾";position:absolute;right:5px;top:50%;transform:translateY(-50%);font-size:8px;
2073
+ color:var(--dsw-alias-label-tertiary,#8b94a8);pointer-events:none;}
2074
+ /* 窗口估算:Left/Total 两段式 + 三个读数;窄栏自己折行,不去挤比例数字 */
2075
+ .qm-est{display:inline-flex;flex-wrap:wrap;align-items:baseline;gap:2px 8px;margin-left:auto;
2076
+ font-size:11px;color:var(--dsw-alias-label-secondary,#a9b2c5);min-width:0;}
2077
+ .qm-est-seg{display:inline-flex;flex:none;border:1px solid var(--dsw-alias-border-l2,#363d4d);
2078
+ border-radius:6px;overflow:hidden;}
2079
+ .qm-card .qm-est-seg button{font:inherit;font-size:10px;line-height:15px;padding:1px 6px;border:0;border-radius:0;
2080
+ background:none;color:var(--dsw-alias-label-tertiary,#8b94a8);cursor:pointer;}
2081
+ .qm-card .qm-est-seg button+button{border-left:1px solid var(--dsw-alias-border-l2,#363d4d);}
2082
+ .qm-card .qm-est-seg button[aria-pressed=true]{background:var(--dsw-alias-interactive-bg-hover,#20283a);
2083
+ color:var(--dsw-alias-label-primary,#dbe2ee);}
2084
+ .qm-est-q{white-space:nowrap;color:var(--dsw-alias-label-secondary,#a9b2c5);}
2085
+ .qm-est-q b{font-weight:600;color:var(--dsw-alias-label-primary,#dbe2ee);font-variant-numeric:tabular-nums;}
2086
+ .qm-est-q.is-off{color:var(--dsw-alias-label-tertiary,#5c6577);}
1981
2087
  /* 分布条:详情行只有一条五段条(命中/未命中/输出/写缓存/未细分);段宽 = 分量占比 */
1982
2088
  .qm-split-bar{display:inline-flex;flex:1 1 64px;min-width:48px;height:5px;border-radius:2.5px;overflow:hidden;
1983
2089
  background:var(--dsw-alias-border-l1,#1d2330);}
@@ -2005,8 +2111,11 @@ div:has(> div[data-slot="sidebar.footer.action"] > [data-qm-linerow]){flex-wrap:
2005
2111
  .qm-chart-plot svg{display:block;width:100%;height:100%;overflow:visible;}
2006
2112
  /* 柱值标签:按绘图区百分比定位,文字本身不参与 svg 的横向拉伸 */
2007
2113
  .qm-chart-labels{position:absolute;inset:0;pointer-events:none;}
2114
+ /* 柱值标签:按绘图区百分比定位,文字本身不参与 svg 的横向拉伸。
2115
+ 标签不可透明:底下一层卡片底色把折线挡开,数字才不会被线压花(标签层在 SVG 之上)。 */
2008
2116
  .qm-chart-value{position:absolute;transform:translateX(-50%);font-size:10px;line-height:1;white-space:nowrap;
2009
- font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary,#a9b2c5);pointer-events:none;}
2117
+ font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary,#a9b2c5);pointer-events:none;
2118
+ padding:1px 3px;border-radius:3px;background:var(--dsw-alias-bg-layer-2,#151922);}
2010
2119
  .qm-chart-bar{fill:var(--dsw-alias-state-business-primary,#4f8cff);fill-opacity:.32;}
2011
2120
  .qm-chart-grid{stroke:var(--dsw-alias-border-l2,#363d4d);stroke-width:1;stroke-dasharray:3 3;vector-effect:non-scaling-stroke;}
2012
2121
  .qm-chart-axis{stroke:var(--dsw-alias-border-l2,#363d4d);stroke-width:1;vector-effect:non-scaling-stroke;}
package/lib/index.js CHANGED
@@ -34,7 +34,8 @@ import { createUsageRecorder } from "./usage.js";
34
34
  import { createRouteResolver } from "./routes.js";
35
35
  import { autoFillPatch, DSH_ROUTE_MAP, detectHarnessSuppliers, resolveCredentialRef } from "./detect.js";
36
36
  import { PROVIDERS, SUPPLIER_IDS } from "./providers.js";
37
- import { sumDayTokens, splitDayTokens, dayUsageHistory, dshHome, acquireUsageStore, migrateUsageDir, quotaFilePath } from "./storage.js";
37
+ import { sumDayTokens, splitDayTokens, dayUsageHistory, dshHome, acquireUsageStore, migrateUsageDir,
38
+ quotaFilePath, hourTimeMs, totalOf } from "./storage.js";
38
39
  import { acquireQuotaStore, clipSamples, downsampleSamples, QUOTA_RETENTION_DAYS, QUOTA_SEND_POINTS } from "./quota-history.js";
39
40
  import { describeSeason, normalizeHolidays, validateHolidayOverride, tierOfHour } from "./season.js";
40
41
 
@@ -351,6 +352,61 @@ export function apply(ctx, config) {
351
352
  return { currentIds, todayOf, splitOf, daysOf, historyDays, trafficStale, holidays, now };
352
353
  }
353
354
 
355
+ /**
356
+ * 窗口估算的取样:同一段折线里,两条额度采样之间本地烧掉多少 token、百分比走了多少点。
357
+ *
358
+ * 分母必须是 Δpp(斜率),不能是「已用%」(电平):窗口起点若早于本地留存,分子只有最近几天,
359
+ * 除以累计百分比会把总量算成一半(3B 降 50pp 应该推出 6B,而不是 3B ÷ 90%)。
360
+ * 两端必须落在同一条折线段上(中间有重置就取最后一次之后),左端再从本地还留着的小时桶起算。
361
+ * 窗口长度由供应商声明(entries[].windowMs);没声明就没有估算。
362
+ * @returns {{from:number,to:number,tokens:number,dPct:number}|null}
363
+ */
364
+ function estimateOf(id, entry, now = Date.now()) {
365
+ const windowMs = Number(entry?.windowMs);
366
+ const resetAt = Number(entry?.resetAt);
367
+ if (!Number.isFinite(windowMs) || windowMs <= 0 || !Number.isFinite(resetAt)) return null;
368
+ const samples = (quotaStore?.store.quota?.[id]?.[entry.key] || [])
369
+ .filter((pair) => Array.isArray(pair) && Number.isFinite(pair[0]) && Number.isFinite(pair[1]));
370
+ if (samples.length < 2) return null;
371
+ // 只取当前窗口这一段:额度在窗口内只升不降,最后一个回落点之前是上一轮
372
+ let start = 0;
373
+ for (let i = 1; i < samples.length; i++) if (samples[i][1] < samples[i - 1][1]) start = i;
374
+ const current = samples.slice(start);
375
+ if (current.length < 2) return null;
376
+ // 分子只能覆盖本地还留着的桶:左端取「窗口起点」与「留存起点」里较晚的那个
377
+ const retentionMs = Math.max(1, Math.floor(runtime.config.retentionDays || 7)) * 86400_000;
378
+ const from = Math.max(resetAt - windowMs, now - retentionMs);
379
+ const first = current.find(([at]) => at >= from);
380
+ if (!first) return null;
381
+ const last = current[current.length - 1];
382
+ const dPct = Math.round((last[1] - first[1]) * 10) / 10;
383
+ // 少于两点百分比(整数上报)或不到一小时,斜率就是噪声
384
+ if (!(dPct >= 2) || last[0] - first[0] < 3600_000) return null;
385
+ const hours = runtime.usageBuckets[id];
386
+ if (!hours) return null;
387
+ let tokens = 0;
388
+ for (const [key, breakdown] of Object.entries(hours)) {
389
+ const at = hourTimeMs(key);
390
+ // 小时桶覆盖 [at, at+1h):与取样区间有重叠就整段计入(边界那一小时会略微多算)
391
+ if (at + 3600_000 > first[0] && at <= last[0]) tokens += totalOf(breakdown);
392
+ }
393
+ if (tokens <= 0) return null;
394
+ return { from: first[0], to: last[0], tokens, dPct };
395
+ }
396
+
397
+ /** 给窗口类限额项挂上 estimate;其它类型(余额、报告)原样返回。 */
398
+ function withEstimate(id, last) {
399
+ if (!Array.isArray(last?.entries) || !last.entries.length) return last;
400
+ return {
401
+ ...last,
402
+ entries: last.entries.map((entry) => {
403
+ if (entry?.kind !== "win") return entry;
404
+ const estimate = estimateOf(id, entry);
405
+ return estimate === null ? entry : { ...entry, estimate };
406
+ }),
407
+ };
408
+ }
409
+
354
410
  // ---------- 「当前在用」供应商(小组件紧凑条主显示) ----------
355
411
  // 语义(用户选定):最近一次真实 LLM 调用命中的供应商 + 模型名。
356
412
  // 只统计能映射到插件供应商的 route(session/event 已把 route/model 折叠进各 routeSeen 表);
@@ -614,7 +670,7 @@ export function apply(ctx, config) {
614
670
  needs: (meta.needs || []).map((n) => ({ key: n.key, label: n.label, secret: !!n.secret })),
615
671
  },
616
672
  ...(s.last
617
- ? s.last
673
+ ? withEstimate(id, s.last)
618
674
  : { state: "off", error: { code: "no-run", message: "尚未取数" }, headline: { kind: "amt", amt: "—" } }),
619
675
  // 成功数据时间独立于 fetchedAt;失败尝试只更新状态,不能冒充新数据。
620
676
  ...(s.lastSuccessAt != null ? { lastSuccessAt: s.lastSuccessAt } : {}),
package/lib/providers.js CHANGED
@@ -786,7 +786,13 @@ async function queryOpenCode(cfg) {
786
786
  const body = await httpJson(`${server}/zen/go/v1/usage`, { key: cfg.apiKey });
787
787
  const usage = body?.usage;
788
788
  if (usage && typeof usage === "object") {
789
- for (const [field, label, short] of [["rolling", "5h 滚动", "5h"], ["weekly", "周用量", "周"], ["monthly", "月用量", "月"]]) {
789
+ // windowMs:窗口长度,供宿主把本地小时桶折成「这个窗口里烧了多少 token」。
790
+ // 只在这里声明,因为只有供应商自己知道窗口多长;缺它的限额项不会得到估算。
791
+ for (const [field, label, short, windowMs] of [
792
+ ["rolling", "5h 滚动", "5h", 5 * 3600_000],
793
+ ["weekly", "周用量", "周", 7 * 86400_000],
794
+ ["monthly", "月用量", "月", 30 * 86400_000],
795
+ ]) {
790
796
  const w = usage[field];
791
797
  if (!w || typeof w !== "object") continue;
792
798
  const pct = parseNumber(w.percent);
@@ -796,6 +802,7 @@ async function queryOpenCode(cfg) {
796
802
  key: `window.${field}`,
797
803
  short,
798
804
  kind: "win",
805
+ windowMs,
799
806
  limit: "—",
800
807
  used: pct === null ? "—" : `${pct}%`,
801
808
  remain: pct === null ? "—" : `${Math.max(0, 100 - pct)}%`,
@@ -925,9 +932,9 @@ async function queryCommandCode(cfg) {
925
932
  let tightest = null;
926
933
 
927
934
  const windowLimits = creditsBody?.windowLimits;
928
- for (const [field, label, slot, short] of [
929
- ["fiveHour", "5h 窗口", "window.5h", "5h"],
930
- ["weekly", "周窗口", "window.weekly", "周"],
935
+ for (const [field, label, slot, short, windowMs] of [
936
+ ["fiveHour", "5h 窗口", "window.5h", "5h", 5 * 3600_000],
937
+ ["weekly", "周窗口", "window.weekly", "周", 7 * 86400_000],
931
938
  ]) {
932
939
  const w = windowLimits?.[field];
933
940
  if (!w || typeof w !== "object") continue;
@@ -948,6 +955,7 @@ async function queryCommandCode(cfg) {
948
955
  key: slot,
949
956
  short,
950
957
  kind: "win",
958
+ windowMs,
951
959
  limit: formatMoney(cap, "USD"),
952
960
  used: used === null ? "—" : formatMoney(used, "USD"),
953
961
  remain: remain === null ? "—" : formatMoney(remain, "USD"),
@@ -982,6 +990,10 @@ async function queryCommandCode(cfg) {
982
990
  key: "window.monthly",
983
991
  short: "月",
984
992
  kind: "win",
993
+ // 订阅周期就是窗口:起止都给了才声明长度,只有结束时刻就留空(估算宁可不给)
994
+ windowMs: periodEnd && periodStart && !Number.isNaN(periodStart.getTime())
995
+ ? Math.max(0, periodEnd.getTime() - periodStart.getTime())
996
+ : null,
985
997
  limit: formatMoney(planLimit, "USD"),
986
998
  used: formatMoney(used, "USD"),
987
999
  remain: formatMoney(remaining, "USD"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ds-hu/dsh-token-quota",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "DeepSeek Harness 用量监控:官方侧栏完整/紧凑卡片与单一面板的概览、供应商配置,按当前会话显示周期限额、逐币种余额与报告用量费用;保留凭据类别隔离、自动识别普通 API Key 与本地今日用量统计。",
5
5
  "icon": "./icon.svg",
6
6
  "author": "shxtmaker <https://github.com/shxtmaker>",
@@ -352,12 +352,18 @@ test("expanding a supplier shows token bars and remaining-quota lines in one cha
352
352
  await mkdir(statsDirectory, { recursive: true });
353
353
  await page.screenshot({ path: resolve(statsDirectory, "stats-combined-chart.png"), fullPage: true });
354
354
 
355
- // 线上的形状不给数字:悬停把那一刻的读数放进浮层(最近一次采样,不插值),并落一条十字线
355
+ // 线上的形状不给数字:悬停把那一刻的读数放进浮层(最近一次采样,不插值),并落一条十字线。
356
+ // 落点取最后一根柱(今天)的中心,而不是「绘图区宽度的 94%」:横轴终点是此刻,6% ≈ 10 小时,
357
+ // 凌晨跑的 CI 会因此退回昨天(UTC 03:02 那次落到 10-31 18:12,浮层里没有 token 行)。
358
+ // 柱的坐标本身就是「今天」,与运行时刻无关。
356
359
  await plot.scrollIntoViewIfNeeded();
357
- const box = await plot.boundingBox();
358
- await page.mouse.move(box.x + box.width * 0.94, box.y + box.height / 2);
360
+ const todayLabel = dayLabels(7).at(-1).slice(5);
361
+ const lastBar = await plot.locator("rect.qm-chart-bar").last().boundingBox();
362
+ const plotBox = await plot.boundingBox();
363
+ await page.mouse.move(lastBar.x + lastBar.width / 2, plotBox.y + plotBox.height / 2);
359
364
  const tip = row.locator(".qm-chart-tip");
360
365
  await expect(tip).toBeVisible();
366
+ await expect(tip).toContainText(todayLabel);
361
367
  await expect(tip).toContainText("剩余");
362
368
  await expect(tip).toContainText("3.15M token");
363
369
  await expect(plot.locator(".qm-chart-cursor")).toHaveCount(1);
package/test/client.mjs CHANGED
@@ -5,10 +5,11 @@ import vm from "node:vm";
5
5
  import React from "react";
6
6
  import { create, act } from "react-test-renderer";
7
7
 
8
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
8
9
  const source = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
9
10
  /** 使用实际无构建客户端与真实 React,仅替换宿主槽位、网络、时钟和本地存储。 */
10
11
  function client(fetch, { storage = new Map(), search = "", clock = null, mobile = false } = {}) {
11
- let plugin, dictionary;
12
+ let plugin, dictionary, registeredId;
12
13
  const locales = {};
13
14
  const slots = {}, timers = [], listeners = {}, cleanups = [];
14
15
  const subscribe = (type, fn) => (listeners[type] ||= new Set()).add(fn);
@@ -21,7 +22,7 @@ function client(fetch, { storage = new Map(), search = "", clock = null, mobile
21
22
  body: {}, visibilityState: "visible", addEventListener: subscribe, removeEventListener: unsubscribe,
22
23
  };
23
24
  const window = {
24
- __ModuleLoader__: { load({ factory }) { plugin = factory((id) => id === "react" ? React : { createPortal: (child) => child }); } },
25
+ __ModuleLoader__: { load({ id, factory }) { registeredId = id; plugin = factory((id) => id === "react" ? React : { createPortal: (child) => child }); } },
25
26
  location: { search }, addEventListener: subscribe, removeEventListener: unsubscribe,
26
27
  localStorage: { getItem: (key) => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
27
28
  // 视口断点替身:mobile 决定 matchMedia 的结果,宿主浏览器才有真实实现。
@@ -50,7 +51,7 @@ function client(fetch, { storage = new Map(), search = "", clock = null, mobile
50
51
  inject(name, fn) { return fn(); }, register(descriptor, component) { slots[descriptor.name ?? descriptor.key] = component; },
51
52
  } });
52
53
  const t = (key, params = {}) => String(dictionary[key] ?? key).replace(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
53
- return { Footer: slots["sidebar.footer.action"], Page: slots["settings.section"], t, locales, timers, storage, helpers: plugin.__test, setNow: context.__setNow };
54
+ return { registeredId, Footer: slots["sidebar.footer.action"], Page: slots["settings.section"], t, locales, timers, storage, helpers: plugin.__test, setNow: context.__setNow };
54
55
  }
55
56
  const textOf = (node) => {
56
57
  const walk = (child) => Array.isArray(child) ? child.map(walk).join("") : typeof child === "string" || typeof child === "number" ? String(child) : child?.children ? walk(child.children) : "";
@@ -82,6 +83,13 @@ const response = (data) => ({ ok: true, status: 200, json: async () => data });
82
83
  const payload = (name, suppliers = []) => ({ ok: true, traffic: { channelAlive: true }, suppliers, active: { supplierId: suppliers[0]?.id, name, model: "model", at: Date.now() } });
83
84
  const supplier = (entries, extra = {}) => ({ id: "opencode", name: "OpenCode", current: true, added: true, enabled: true, state: "ok", todayTokens: 0, entries, ...extra });
84
85
 
86
+ // 包名与产物里注册的 id 必须逐字符一致:DSH 浏览器半身按包名请求 <pkg>/client,
87
+ // 注册成别的名字时整条 entry 静默不激活,页面只报 loaded without registering。
88
+ test("the bundle registers itself under the package name", async () => {
89
+ const host = client(async () => response(payload("A")));
90
+ assert.equal(host.registeredId, manifest.name);
91
+ });
92
+
85
93
  for (const wide of [true, false]) test(`${wide ? "expanded" : "rail"} sidebar opens the single monitor panel`, async () => {
86
94
  const host = client(async () => response(payload("A")));
87
95
  let tree;
@@ -358,7 +366,7 @@ test("the card draws one bar per watched limit with its own countdown and no tod
358
366
  // 今日用量搬进详情弹窗的供应商栏
359
367
  await open(tree);
360
368
  await expand(tree, "opencode");
361
- assert.match(textOf(rowOf(tree, "opencode").findByProps({ className: "qm-today-line" })), /今日用量 1\.24M tokens/);
369
+ assert.match(textOf(rowOf(tree, "opencode").findByProps({ className: "qm-today-line" })), /1\.24M tokens/);
362
370
  } finally { await act(async () => tree?.unmount()); }
363
371
  });
364
372
 
@@ -395,7 +403,7 @@ test("details show the colored ratio next to today's total, and only when compon
395
403
  await open(tree);
396
404
  await expand(tree, "opencode");
397
405
  const line = rowOf(tree, "opencode").findByProps({ className: "qm-today-line" });
398
- assert.match(textOf(line), /今日用量 3\.15M tokens/, "总量仍按供应商口径");
406
+ assert.match(textOf(line), /3\.15M tokens/, "总量仍按供应商口径;区间由选择器表达");
399
407
  assert.match(textOf(line), /30\.0 : 1\.00 : 0\.150/, "比例挨着总量:命中 : 未命中 : 输出,未命中 = 1");
400
408
  // 三个数字分别着段色,与下方五段条的前三段一一对应
401
409
  const ratio = line.findByProps({ className: "qm-ratio" });
@@ -410,13 +418,83 @@ test("details show the colored ratio next to today's total, and only when compon
410
418
  await open(tree);
411
419
  await expand(tree, "opencode");
412
420
  const bare = rowOf(tree, "opencode").findByProps({ className: "qm-today-line" });
413
- assert.match(textOf(bare), /今日用量 500 tokens/);
421
+ assert.match(textOf(bare), /500 tokens/);
414
422
  assert.equal(withClass(bare, "qm-split-bar").length, 0);
415
423
  assert.equal(withClass(rowOf(tree, "opencode"), "qm-split-bar").length, 0);
416
424
  assert.equal(bare.findAllByProps({ className: "qm-ratio" }).length, 0);
417
425
  } finally { await act(async () => tree?.unmount()); }
418
426
  });
419
427
 
428
+ test("the today line switches range and derives each window's token estimate", async () => {
429
+ const usageDays = [
430
+ { day: "2026-10-26", inputTokens: 10, outputTokens: 1, cacheReadTokens: 100, cacheWriteTokens: 0, unsplitTokens: 0, total: 111 },
431
+ { day: "2026-10-27", inputTokens: 100, outputTokens: 16, cacheReadTokens: 3_000, cacheWriteTokens: 40, unsplitTokens: 0, total: 3_155_000 },
432
+ ];
433
+ const hour = 3600_000;
434
+ const at = Date.now();
435
+ const estimate = (tokens, dPct, hours) => ({ from: at - hours * hour, to: at - hour, tokens, dPct });
436
+ const entries = [
437
+ // 每 1pp = 34M ÷ 20 = 1.7M:剩余 64pp ⇒ ≈109M,满 100pp ⇒ ≈170M
438
+ { name: "5h 滚动", key: "window.5h", short: "5h", kind: "win", pct: 36, estimate: estimate(34_000_000, 20, 5) },
439
+ { name: "周用量", key: "window.weekly", short: "周", kind: "win", pct: 53, estimate: estimate(2_500_000_000, 30, 72) },
440
+ { name: "月用量", key: "window.monthly", short: "月", kind: "win", pct: 89, estimate: estimate(3_150_000_000, 30, 96) },
441
+ // 只走了 1pp:整数百分比的舍入误差撑不起斜率,宁可给「—」
442
+ { name: "新窗口", key: "window.tiny", short: "新", kind: "win", pct: 2, estimate: estimate(9_000_000, 1, 3) },
443
+ ];
444
+ const state = payload("OpenCode", [supplier(entries, {
445
+ todayTokens: 382_000_000,
446
+ todayUsage: { inputTokens: 100, outputTokens: 16, cacheReadTokens: 3_000, cacheWriteTokens: 40, unsplitTokens: 0 },
447
+ usageDays,
448
+ })]);
449
+ const host = client(async () => response(state));
450
+ let tree;
451
+ try {
452
+ tree = await mount(host);
453
+ await open(tree);
454
+ await expand(tree, "opencode");
455
+ const today = rowOf(tree, "opencode").findByProps({ className: "qm-today-line" });
456
+
457
+ // 「Today」两个字换成了选择器,右边的文字不再重复区间名
458
+ const range = today.findByType("select");
459
+ assert.deepEqual(range.findAllByType("option").map((node) => node.props.value), ["today", "7d"]);
460
+ assert.equal(range.props.value, "today");
461
+ assert.equal(textOf(today.findByProps({ className: "qm-today-text" })), "382M tokens");
462
+
463
+ // 估算按斜率折算:34M ÷ Δ20pp = 1.7M/pp,剩余 64pp ⇒ ≈109M
464
+ const chips = withClass(today, "qm-est-q");
465
+ assert.deepEqual(chips.map(textOf), ["5h ≈109M", "周 ≈3.92B", "月 ≈1.16B", "新 —"]);
466
+ assert.match(chips[0].props.title, /4h: 34\.0M ÷ Δ20pp/, "title 里给出取样区间与分子分母");
467
+ assert.match(chips[0].props.title, /Δ20pp ±1pp → 104M–115M/);
468
+ assert.equal(chips[3].props.title, "本地样本不足");
469
+
470
+ // 切到套餐总量:同一份斜率,按满 100pp 折算
471
+ const [left, total] = today.findAllByProps({ className: "qm-est-seg" })[0].findAllByType("button");
472
+ assert.equal(left.props["aria-pressed"], true);
473
+ await act(async () => total.props.onClick());
474
+ const totals = withClass(today, "qm-est-q").map(textOf);
475
+ assert.deepEqual(totals, ["5h ≈170M", "周 ≈8.33B", "月 ≈10.5B", "新 —"]);
476
+
477
+ // 切到近 7 天:数量、比例、五分量条一起换,估算仍按窗口(不随区间变)
478
+ await act(async () => range.props.onChange({ target: { value: "7d" } }));
479
+ const line7d = rowOf(tree, "opencode").findByProps({ className: "qm-today-line" });
480
+ assert.equal(textOf(line7d.findByProps({ className: "qm-today-text" })), "3.16M tokens");
481
+ assert.match(textOf(line7d.findByProps({ className: "qm-ratio" })), /28\.2 : 1\.00 : 0\.155/);
482
+ assert.deepEqual(withClass(line7d, "qm-est-q").map(textOf), totals);
483
+
484
+ // 没有本地用量 → 不给估算(不编 0,也不编比例)
485
+ const bare = client(async () => response(payload("OpenCode", [supplier(
486
+ [{ name: "周用量", key: "window.weekly", short: "周", kind: "win", pct: 40 }],
487
+ { todayTokens: 500 })])));
488
+ let bareTree;
489
+ try {
490
+ bareTree = await mount(bare);
491
+ await open(bareTree);
492
+ await expand(bareTree, "opencode");
493
+ assert.equal(rowOf(bareTree, "opencode").findAllByProps({ className: "qm-est" }).length, 0);
494
+ } finally { await act(async () => bareTree?.unmount()); }
495
+ } finally { await act(async () => tree?.unmount()); }
496
+ });
497
+
420
498
  test("expanding a supplier shows today's ratio, the five-part split and one combined chart", async () => {
421
499
  const start = (day) => new Date(...day.split("-").map((part, i) => (i === 1 ? Number(part) - 1 : Number(part)))).getTime();
422
500
  const usageDays = [
package/test/copy.mjs CHANGED
@@ -42,6 +42,8 @@ test("removed surfaces and long explanatory paragraphs stay out of the dictionar
42
42
  });
43
43
 
44
44
  test("locale stays within the copy budget", () => {
45
- assert.ok(zh.size <= 160, `中文词典 ${zh.size} 键,超出预算`);
46
- assert.ok(en.size <= 160, `英文词典 ${en.size} 键,超出预算`);
45
+ // 160 → 163:今日行的区间选择器(rangeLabel、range7d)、估算口径切换(estLeft、estTotal)
46
+ // 与「样本不足」的悬停说明(estNoData)共五条;七天的句子、算式与区间都由数字拼出来,没有另开键。
47
+ assert.ok(zh.size <= 163, `中文词典 ${zh.size} 键,超出预算`);
48
+ assert.ok(en.size <= 163, `英文词典 ${en.size} 键,超出预算`);
47
49
  });
package/test/runtime.mjs CHANGED
@@ -7,7 +7,7 @@ import { apply, Config } from "../lib/index.js";
7
7
  import { createCtx } from "./harness.mjs";
8
8
  import { PROVIDERS } from "../lib/providers.js";
9
9
  import * as season from "../lib/season.js";
10
- import { dayLabelOf } from "../lib/storage.js";
10
+ import { dayLabelOf, hourKeyOf } from "../lib/storage.js";
11
11
 
12
12
  test("plugin instances do not share traffic and dispose releases event handlers", async () => {
13
13
  const originalHome = process.env.DSH_HOME;
@@ -322,6 +322,61 @@ test("a successful query appends a quota sample, and an unchanged value does not
322
322
  assert.deepEqual(saved.suppliers.opencode["window.rolling"].map(([, pct]) => pct), [12, 40]);
323
323
  });
324
324
 
325
+ test("窗口估算按 Δpp 取样:分子只覆盖本地还留着的桶,左端落在窗口起点", async (t) => {
326
+ const previousHome = process.env.DSH_HOME;
327
+ const previousFetch = globalThis.fetch;
328
+ const home = mkdtempSync(join(tmpdir(), "qm-window-est-"));
329
+ process.env.DSH_HOME = home;
330
+ const now = Date.now();
331
+ const hour = 3600_000;
332
+ mkdirSync(join(home, "dsh-token-quota"), { recursive: true });
333
+ writeFileSync(join(home, "dsh-token-quota", "usage.json"), JSON.stringify({
334
+ version: 2,
335
+ buckets: { opencode: {
336
+ [hourKeyOf(new Date(now - 2 * hour))]: { inputTokens: 100 },
337
+ [hourKeyOf(new Date(now - 20 * hour))]: { inputTokens: 500 },
338
+ [hourKeyOf(new Date(now - 100 * hour))]: { inputTokens: 9_999 },
339
+ } },
340
+ }), "utf8");
341
+ // 额度折线:rolling 只记了本窗口的 30→40;weekly 从 40 小时前的 0 一路涨到 14
342
+ writeFileSync(join(home, "dsh-token-quota", "quota.json"), JSON.stringify({
343
+ version: 1,
344
+ suppliers: { opencode: {
345
+ "window.rolling": [[now - 3 * hour, 30], [now - hour, 40]],
346
+ "window.weekly": [[now - 40 * hour, 0], [now - 3 * hour, 10], [now - hour, 14]],
347
+ } },
348
+ }), "utf8");
349
+ globalThis.fetch = async (url) => {
350
+ if (!String(url).includes("/zen/go/v1/usage")) return { ok: false, status: 401, text: async () => "" };
351
+ return {
352
+ ok: true, status: 200, text: async () => JSON.stringify({ usage: {
353
+ // rolling:重置在 2 小时后、窗口 5h ⇒ 左缘 now−3h,与第一条采样对齐
354
+ rolling: { percent: 40, resetsAt: new Date(now + 2 * hour).toISOString() },
355
+ // weekly:重置在 4 天后、窗口 7d ⇒ 左缘 now−3d,样本从 40 小时前开始算
356
+ weekly: { percent: 14, resetsAt: new Date(now + 4 * 86400_000).toISOString() },
357
+ } }),
358
+ };
359
+ };
360
+ const harness = createCtx({ schema: Config, config: { suppliers: { opencode: { enabled: true, apiKey: "sk-test" } } } });
361
+ const dispose = apply(harness.ctx, harness.configRef);
362
+ t.after(() => {
363
+ dispose();
364
+ globalThis.fetch = previousFetch;
365
+ if (previousHome === undefined) delete process.env.DSH_HOME;
366
+ else process.env.DSH_HOME = previousHome;
367
+ rmSync(home, { recursive: true, force: true });
368
+ });
369
+
370
+ await harness.call("/api/dsh-token-quota/refresh", {});
371
+ const opencode = (await harness.call("/api/dsh-token-quota/state")).suppliers.find((s) => s.id === "opencode");
372
+ const byKey = Object.fromEntries(opencode.entries.map((entry) => [entry.key, entry]));
373
+ assert.equal(byKey["window.rolling"].windowMs, 5 * 3600_000, "窗口长度由供应商声明");
374
+ // 5h:取样 = [now−3h, now−1h],只有 2 小时前那一桶(100)落在里面
375
+ assert.deepEqual(byKey["window.rolling"].estimate, { from: now - 3 * hour, to: now - hour, tokens: 100, dPct: 10 });
376
+ // 周:取样从 40 小时前开始,2 小时前与 20 小时前两桶都算(600),100 小时前那桶在左缘之外
377
+ assert.deepEqual(byKey["window.weekly"].estimate, { from: now - 40 * hour, to: now - hour, tokens: 600, dPct: 14 });
378
+ });
379
+
325
380
  test("a retried attempt adds to the same step instead of replacing it", async (t) => {
326
381
  const previousHome = process.env.DSH_HOME;
327
382
  const home = mkdtempSync(join(tmpdir(), "qm-retry-"));