@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 +22 -0
- package/README.md +28 -15
- package/lib/client.js +118 -9
- package/lib/index.js +58 -2
- package/lib/providers.js +16 -4
- package/package.json +1 -1
- package/test/browser/interactions.spec.mjs +9 -3
- package/test/client.mjs +84 -6
- package/test/copy.mjs +4 -2
- package/test/runtime.mjs +56 -1
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.
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
$
|
|
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
|
-
|
|
107
|
+
离线安装时把包名换成压缩包路径:
|
|
102
108
|
|
|
103
109
|
```powershell
|
|
104
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
145
|
-
cd dsh-
|
|
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.
|
|
266
|
-
node scripts/verify-web.mjs --app 'C:/path/DeepSeek Harness.exe' --package 'C:/path/dsh-token-quota-2.
|
|
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, {
|
|
1192
|
-
|
|
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
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|
358
|
-
|
|
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" })),
|
|
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),
|
|
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),
|
|
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
|
-
|
|
46
|
-
|
|
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-"));
|