dsh-tokenlens 1.6.6 → 1.7.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 +20 -2
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/client.js +185 -10
- package/index.js +1 -1
- package/lib/providers.mjs +16 -3
- package/lib/usage-stats.mjs +143 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,27 @@
|
|
|
4
4
|
|
|
5
5
|
**版本记录约定**:同一发布日只保留一个章节,标题取当日**最后一个**已发布版本号;当日多次发布的改动合并到该章节,并在标题下注明当日实际发布过的版本号(npm 上仍可安装这些独立版本)。若当日已有发布,后续小改动不再单独增加版本号,而是并入下一篇日志。这样日志按天规整、不出现同日多个小版本。
|
|
6
6
|
|
|
7
|
-
## 1.
|
|
7
|
+
## 1.7.0 — 2026-11-06
|
|
8
8
|
|
|
9
|
-
(当日已发布版本:v1.6.5 / v1.6.6)
|
|
9
|
+
(当日已发布版本:v1.6.5 / v1.6.6 / v1.6.7 / v1.7.0)
|
|
10
|
+
|
|
11
|
+
- **fix(ui): 弹窗改为 portal 到 `<body>`,恢复视口居中**——DSH 0.2.2-rc.1 里 `conversation.input.overlay` 槽位的祖先建立了包含块(transform / filter / contain),`position: fixed` 因此相对「对话区」解析,弹窗只能贴在对话区底部。改用 `react-dom` 的 `createPortal` 挂到 `document.body`,彻底脱离该祖先链。`react-dom` 缺失时(含 Node 测试环境)自动回退为原地渲染,绝不抛错
|
|
12
|
+
- **fix(ui): 芯片悬停不再让整个对话区闪烁**——芯片同时设置了原生 `title` 与平台 `Tooltip`,两者在悬停时互相抢占/释放,造成区域反复重绘。现在只保留平台 `Tooltip`(文案一致,`aria-label` 保留),移除冗余的原生 `title`
|
|
13
|
+
- **feat(ui): 新增「输出速度(TPS)」趋势图**,位于明细下方、独占整行——输出 Token ÷ 模型自身耗时(`step/start` → `assistant/message`)。工具调用发生在消息之后,因此等待时间不被计入;输出 Token 含思考 Token,弹窗提示已说明
|
|
14
|
+
- feat(stats): Host 侧新增 `speed` 序列(顶层为全 Harness 合计,每个 provider 行另有自己的序列,跟随作用域切换);按天聚合 `{day, outputTokens, seconds, tps, steps}`,无正耗时的日期直接跳过而不是给出无限速率。缓存 schema 升至 6,首次会重扫
|
|
15
|
+
- **fix(stats): 「最近X天」封顶 30 天**——此前 provider 首次出现超过 30 天时会报「最近90天」,而同期所有其他数字与图表都只覆盖 30 天。现在 `effectiveDays` 封顶 30,且 `sinceEffective` 在超窗时改用 30 天桶,保证**标签与数字描述同一区间**;`daily` 序列尾部由 120 天收敛为 30 天
|
|
16
|
+
- 测试:357 → 363(TPS 配对与未配对步骤跳过、零耗时日期跳过、provider 作用域序列、30 天封顶与标签/数据一致、弹窗 portal 与无 react-dom 回退、TPS 图表与空态)
|
|
17
|
+
|
|
18
|
+
## 1.7.0 之前的同日版本见下方各节
|
|
19
|
+
|
|
20
|
+
## 1.6.7 — 2026-11-05
|
|
21
|
+
|
|
22
|
+
(当日已发布版本:v1.6.5 / v1.6.6 / v1.6.7)
|
|
23
|
+
|
|
24
|
+
- **fix(commandcode): 用实时窗口用量校正滞后的 `usage summary`**——`/alpha/usage/summary` 是聚合接口,比滚动窗口**滞后几美分**(实测 summary `$1.1248` vs 实时 `$1.2802`),直接拿它反推会让额度偏小、百分比偏低约 0.2 个百分点。现在取「summary 与实时窗口用量」中的**最大值**作为本期消费下界——summary 提供锚点,实时窗口只会把估计值推向真值。实测修正后月额度精确回到 **$70.00**、百分比 `1.85%`,与该平台控制台一致
|
|
25
|
+
- fix(commandcode): **仅在 summary 存在时才用窗口用量校正**——没有 summary 就没有锚点,此时周窗口可能并不覆盖整个账期,用它反推会把额度放大、百分比压向 0,因此该情况仍保持 `null`
|
|
26
|
+
|
|
27
|
+
### v1.6.6 同日内容
|
|
10
28
|
|
|
11
29
|
- **feat(commandcode): 补上月用量百分比**——该平台付费套餐**不下发** `monthlyCreditsGranted`(额度分母),此前月窗口只能是 `unknown`。现在同时读 `GET /alpha/usage/summary`(控制台自己用的那个接口),用两个字段反推额度:`grant = credits.monthlyCredits(余额)+ totalMonthlyCredits(本期消费)`。实测 `69.1272003646 + 0.8727996354 = 70.0000000000`(多时点复核均精确 70),与控制台前端内置的 `totalCredits: 70` 一致
|
|
12
30
|
- feat(commandcode): 月窗口因此同时具备**已用 / 额度 / 百分比 / 重置时间**(`$0.99 / $70.00 · 1.4%`),与 5 小时、每周两个窗口口径一致
|
package/README.en.md
CHANGED
|
@@ -144,7 +144,7 @@ Host bridge route: `/api/tokenlens`; agent tool: `tokenlens_usage`.
|
|
|
144
144
|
|
|
145
145
|
## Development
|
|
146
146
|
|
|
147
|
-
Node.js ≥ 24, zero runtime dependencies: `node --test` runs the
|
|
147
|
+
Node.js ≥ 24, zero runtime dependencies: `node --test` runs the 363 tests.
|
|
148
148
|
|
|
149
149
|
Release: `node scripts/bump-version.mjs <version> "<note>"` updates `package.json`, `PLUGIN_VERSION` and `CHANGELOG.md`; see [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
|
|
150
150
|
|
package/README.md
CHANGED
|
@@ -445,7 +445,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
|
|
|
445
445
|
|
|
446
446
|
## 验证
|
|
447
447
|
|
|
448
|
-
- `node --test`:**
|
|
448
|
+
- `node --test`:**363 个自动化用例**,覆盖 Host 数据层、供应商适配器(含小米 MiMo 控制台 Cookie 适配器、Command Code 月度额度反推)、统计折叠、各 harness 读取、Design Plan 周期账本与额度计价、TPS 速度序列、窗口锚点切片、工作区自动识别、插件集成与浏览器端渲染 / 交互。
|
|
449
449
|
- 在真实环境中验证过 **5 个 harness** 的数据源;本机统计与独立的 SQLite 查询、独立的日志解析逐项对账(差额为 0),跨 harness 合并与供应商映射算术自洽。
|
|
450
450
|
- 供应商接口按各官方文档与参考客户端行为验证;未配置凭据时 chip 静默隐藏,接口异常时按可重试错误降级。
|
|
451
451
|
|
package/client.js
CHANGED
|
@@ -40,6 +40,19 @@ window.__ModuleLoader__.load({
|
|
|
40
40
|
// words in the shell's module table, so they need no `dsh.client.external`.
|
|
41
41
|
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
42
42
|
const { Modal, Button, Tooltip, IconRefreshOutlineRegular } = primitives
|
|
43
|
+
// v1.7.0: the dialog is portalled to <body>. Rendering it inside the
|
|
44
|
+
// `conversation.input.overlay` slot stopped reaching the viewport in DSH
|
|
45
|
+
// 0.2.2-rc.1: an ancestor there establishes a containing block (transform /
|
|
46
|
+
// filter / contain), so `position: fixed` resolved against the conversation
|
|
47
|
+
// area and the dialog could only appear at its bottom. `createPortal` escapes
|
|
48
|
+
// that ancestor entirely. Tolerated as optional so a Host without react-dom
|
|
49
|
+
// (and the Node test harness) simply renders inline as before.
|
|
50
|
+
let ReactDOM = null
|
|
51
|
+
try {
|
|
52
|
+
ReactDOM = require('react-dom')
|
|
53
|
+
} catch {
|
|
54
|
+
ReactDOM = null
|
|
55
|
+
}
|
|
43
56
|
|
|
44
57
|
const h = React.createElement
|
|
45
58
|
|
|
@@ -215,6 +228,11 @@ window.__ModuleLoader__.load({
|
|
|
215
228
|
statsProviderFallback: '当前供应商({provider})没有本机记录,显示全部 Harness 合计',
|
|
216
229
|
statsDshTotalLabel: 'DSH 合计',
|
|
217
230
|
statsLast30Days: '最近30天',
|
|
231
|
+
statsSpeedTitle: '输出速度(TPS)',
|
|
232
|
+
statsSpeedHint: '输出 Token ÷ 模型自身耗时(step 开始到消息返回);不含工具调用等待,但输出含思考 Token',
|
|
233
|
+
statsSpeedTotal: '均值 {tps} tok/s',
|
|
234
|
+
statsSpeedEmpty: '暂无可计算的速度样本(需要 step 计时数据)',
|
|
235
|
+
statsSpeedBarTitle: '{date} · {tps} tok/s({tokens} 输出 ÷ {seconds}s)',
|
|
218
236
|
statsLast7Days: '最近7天',
|
|
219
237
|
stats7DaysShort: '7天',
|
|
220
238
|
statsDaysShort: '{n}天',
|
|
@@ -484,6 +502,12 @@ window.__ModuleLoader__.load({
|
|
|
484
502
|
statsProviderFallback: 'No local records for the current provider ({provider}) — showing all-harness totals',
|
|
485
503
|
statsDshTotalLabel: 'DSH total',
|
|
486
504
|
statsLast30Days: 'Last 30 days',
|
|
505
|
+
statsSpeedTitle: 'Output speed (TPS)',
|
|
506
|
+
statsSpeedHint:
|
|
507
|
+
'Output tokens ÷ the model’s own request time (step start to message); tool waits excluded, reasoning tokens included',
|
|
508
|
+
statsSpeedTotal: 'avg {tps} tok/s',
|
|
509
|
+
statsSpeedEmpty: 'No speed samples yet (needs step timing data)',
|
|
510
|
+
statsSpeedBarTitle: '{date} · {tps} tok/s ({tokens} output ÷ {seconds}s)',
|
|
487
511
|
statsLast7Days: 'Last 7 days',
|
|
488
512
|
stats7DaysShort: '7d',
|
|
489
513
|
statsDaysShort: '{n}d',
|
|
@@ -1523,6 +1547,8 @@ window.__ModuleLoader__.load({
|
|
|
1523
1547
|
// v0.22.1: only subscription-backed providers use the 最近X天 window.
|
|
1524
1548
|
subscribed: entry.subscribed === true,
|
|
1525
1549
|
harnesses,
|
|
1550
|
+
// v1.7.0: this provider's own output-speed series.
|
|
1551
|
+
speed: normalizeSpeed(entry.speed),
|
|
1526
1552
|
}
|
|
1527
1553
|
}
|
|
1528
1554
|
|
|
@@ -1557,6 +1583,38 @@ window.__ModuleLoader__.load({
|
|
|
1557
1583
|
}
|
|
1558
1584
|
|
|
1559
1585
|
/** The whole stats payload, or null. Handles v1/v2 (DSH-ish) and v3 (provider-scoped). */
|
|
1586
|
+
/**
|
|
1587
|
+
* One output-speed (TPS) series (v1.7.0): `{byDay, total}` or null.
|
|
1588
|
+
*
|
|
1589
|
+
* `tps` is output tokens over the model's own request time, so a day whose
|
|
1590
|
+
* `seconds` is not positive is dropped rather than shown as an infinite rate.
|
|
1591
|
+
*/
|
|
1592
|
+
function normalizeSpeed(raw) {
|
|
1593
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
1594
|
+
const byDay = Array.isArray(raw.byDay)
|
|
1595
|
+
? raw.byDay
|
|
1596
|
+
.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.day === 'string')
|
|
1597
|
+
.map((entry) => ({
|
|
1598
|
+
day: entry.day,
|
|
1599
|
+
outputTokens: numberOrNull(entry.outputTokens) ?? 0,
|
|
1600
|
+
seconds: numberOrNull(entry.seconds) ?? 0,
|
|
1601
|
+
tps: numberOrNull(entry.tps),
|
|
1602
|
+
steps: numberOrNull(entry.steps) ?? 0,
|
|
1603
|
+
}))
|
|
1604
|
+
.filter((entry) => entry.tps !== null && entry.tps >= 0)
|
|
1605
|
+
: []
|
|
1606
|
+
const totalRaw = raw.total !== null && typeof raw.total === 'object' ? raw.total : {}
|
|
1607
|
+
return {
|
|
1608
|
+
byDay,
|
|
1609
|
+
total: {
|
|
1610
|
+
outputTokens: numberOrNull(totalRaw.outputTokens) ?? 0,
|
|
1611
|
+
seconds: numberOrNull(totalRaw.seconds) ?? 0,
|
|
1612
|
+
tps: numberOrNull(totalRaw.tps),
|
|
1613
|
+
steps: numberOrNull(totalRaw.steps) ?? 0,
|
|
1614
|
+
},
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1560
1618
|
function normalizeStats(raw) {
|
|
1561
1619
|
if (raw === null || typeof raw !== 'object') return null
|
|
1562
1620
|
const version = Number.isFinite(raw.version) ? raw.version : 1
|
|
@@ -1684,6 +1742,8 @@ window.__ModuleLoader__.load({
|
|
|
1684
1742
|
workspaces,
|
|
1685
1743
|
// v0.24.0 daily series for the chart mode (may be empty on old Hosts).
|
|
1686
1744
|
daily,
|
|
1745
|
+
// v1.7.0: output-speed (TPS) series across every harness.
|
|
1746
|
+
speed: normalizeSpeed(raw.speed),
|
|
1687
1747
|
zcode,
|
|
1688
1748
|
}
|
|
1689
1749
|
}
|
|
@@ -3874,7 +3934,10 @@ window.__ModuleLoader__.load({
|
|
|
3874
3934
|
{
|
|
3875
3935
|
type: 'button',
|
|
3876
3936
|
className: 'dsh-tokenlens-chip',
|
|
3877
|
-
|
|
3937
|
+
// v1.7.0: no native `title` here. Setting both a native title and
|
|
3938
|
+
// the platform Tooltip made the two compete on hover (the native
|
|
3939
|
+
// one stole/released hover as it appeared), which flickered the
|
|
3940
|
+
// surrounding conversation area. The Tooltip carries the same text.
|
|
3878
3941
|
'aria-label': t('chipHint'),
|
|
3879
3942
|
'aria-haspopup': 'dialog',
|
|
3880
3943
|
onClick: () => openDialog(sessionId),
|
|
@@ -3909,7 +3972,8 @@ window.__ModuleLoader__.load({
|
|
|
3909
3972
|
{
|
|
3910
3973
|
type: 'button',
|
|
3911
3974
|
className: 'dsh-tokenlens-chip',
|
|
3912
|
-
|
|
3975
|
+
// v1.7.0: same as the normal chip — only the platform Tooltip, so
|
|
3976
|
+
// the native and platform tooltips cannot compete on hover.
|
|
3913
3977
|
'aria-label': label,
|
|
3914
3978
|
'aria-haspopup': 'dialog',
|
|
3915
3979
|
...(needsConfig ? { 'data-needs-config': 'true' } : {}),
|
|
@@ -4976,6 +5040,30 @@ window.__ModuleLoader__.load({
|
|
|
4976
5040
|
store.set({ showDetails: store.get().showDetails !== true })
|
|
4977
5041
|
}
|
|
4978
5042
|
|
|
5043
|
+
/**
|
|
5044
|
+
* Detach `node` from the slot's ancestor chain by rendering it into <body>
|
|
5045
|
+
* (v1.7.0).
|
|
5046
|
+
*
|
|
5047
|
+
* The `conversation.input.overlay` slot lives inside the conversation area,
|
|
5048
|
+
* and in DSH 0.2.2-rc.1 an ancestor there establishes a containing block, so
|
|
5049
|
+
* the overlay's `position: fixed` resolved against that ancestor and the
|
|
5050
|
+
* dialog could only appear at the bottom of the conversation. Portalling
|
|
5051
|
+
* detaches it so it centres on the viewport. Returns the node unchanged when
|
|
5052
|
+
* react-dom or a DOM is unavailable (the Node test harness), so the rendered
|
|
5053
|
+
* tree keeps its shape there.
|
|
5054
|
+
*/
|
|
5055
|
+
function portalize(node) {
|
|
5056
|
+
const canPortal =
|
|
5057
|
+
ReactDOM !== null &&
|
|
5058
|
+
typeof ReactDOM.createPortal === 'function' &&
|
|
5059
|
+
typeof document !== 'undefined' &&
|
|
5060
|
+
document !== null &&
|
|
5061
|
+
document.body !== undefined &&
|
|
5062
|
+
document.body !== null
|
|
5063
|
+
if (!canPortal) return node
|
|
5064
|
+
return ReactDOM.createPortal(node, document.body)
|
|
5065
|
+
}
|
|
5066
|
+
|
|
4979
5067
|
/**
|
|
4980
5068
|
* The dialog shell: a fixed overlay with an explicitly sized panel.
|
|
4981
5069
|
*
|
|
@@ -5007,15 +5095,18 @@ window.__ModuleLoader__.load({
|
|
|
5007
5095
|
/* closing must never throw */
|
|
5008
5096
|
}
|
|
5009
5097
|
}
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5098
|
+
// v1.7.0: portal to <body> so the overlay centres on the viewport instead
|
|
5099
|
+
// of resolving `position: fixed` against the conversation area.
|
|
5100
|
+
return portalize(
|
|
5101
|
+
h(
|
|
5102
|
+
'div',
|
|
5103
|
+
{
|
|
5104
|
+
className: 'dsh-tokenlens-overlay',
|
|
5105
|
+
role: 'presentation',
|
|
5106
|
+
onMouseDown: (event) => {
|
|
5107
|
+
if (event.target === event.currentTarget) close()
|
|
5108
|
+
},
|
|
5017
5109
|
},
|
|
5018
|
-
},
|
|
5019
5110
|
h(
|
|
5020
5111
|
'div',
|
|
5021
5112
|
{
|
|
@@ -5042,6 +5133,7 @@ window.__ModuleLoader__.load({
|
|
|
5042
5133
|
h('div', { className: 'dsh-tokenlens-modal-body' }, children),
|
|
5043
5134
|
footer === undefined || footer === null ? null : h('div', { className: 'dsh-tokenlens-modal-foot' }, footer),
|
|
5044
5135
|
),
|
|
5136
|
+
),
|
|
5045
5137
|
)
|
|
5046
5138
|
}
|
|
5047
5139
|
|
|
@@ -5959,6 +6051,83 @@ window.__ModuleLoader__.load({
|
|
|
5959
6051
|
)
|
|
5960
6052
|
}
|
|
5961
6053
|
|
|
6054
|
+
/**
|
|
6055
|
+
* The output-speed (TPS) trend card (v1.7.0), shown under 明细.
|
|
6056
|
+
*
|
|
6057
|
+
* `tps` is output tokens over the model's OWN request time (`step/start` to
|
|
6058
|
+
* `assistant/message`), so the step's tool calls do not dilute it — but
|
|
6059
|
+
* reasoning tokens are included in "output", which the hint says.
|
|
6060
|
+
*/
|
|
6061
|
+
function buildSpeedSection(state, ctx, t) {
|
|
6062
|
+
const stats = ctx.stats
|
|
6063
|
+
const scoped = ctx.providerScoped ? ctx.currentRow?.speed ?? null : stats?.speed ?? null
|
|
6064
|
+
const series = scoped === null || scoped === undefined ? null : scoped
|
|
6065
|
+
const byDay = series === null ? [] : series.byDay
|
|
6066
|
+
const head = h(
|
|
6067
|
+
'div',
|
|
6068
|
+
{ key: 'head', className: 'dsh-tokenlens-section-head' },
|
|
6069
|
+
h(
|
|
6070
|
+
'span',
|
|
6071
|
+
{ className: 'dsh-tokenlens-section-title' },
|
|
6072
|
+
Icon('chart', 14),
|
|
6073
|
+
t('statsSpeedTitle'),
|
|
6074
|
+
),
|
|
6075
|
+
series !== null && series.total.tps !== null
|
|
6076
|
+
? h(
|
|
6077
|
+
'span',
|
|
6078
|
+
{ className: 'dsh-tokenlens-hint', 'data-speed-total': String(series.total.tps) },
|
|
6079
|
+
t('statsSpeedTotal', { tps: series.total.tps.toFixed(1) }),
|
|
6080
|
+
)
|
|
6081
|
+
: null,
|
|
6082
|
+
)
|
|
6083
|
+
const body =
|
|
6084
|
+
byDay.length === 0
|
|
6085
|
+
? h(
|
|
6086
|
+
'div',
|
|
6087
|
+
{ key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-speed-empty': 'true' },
|
|
6088
|
+
h('span', { key: 'hint', className: 'dsh-tokenlens-hint' }, t('statsSpeedEmpty')),
|
|
6089
|
+
)
|
|
6090
|
+
: (() => {
|
|
6091
|
+
const max = Math.max(...byDay.map((entry) => entry.tps ?? 0), 1)
|
|
6092
|
+
const bars = byDay.map((entry, index) => {
|
|
6093
|
+
const tps = entry.tps ?? 0
|
|
6094
|
+
const height = Math.max(2, Math.round((tps / max) * 1000) / 10)
|
|
6095
|
+
return h(
|
|
6096
|
+
'div',
|
|
6097
|
+
{
|
|
6098
|
+
key: `speed-${entry.day}-${index}`,
|
|
6099
|
+
className: 'dsh-tokenlens-chart-bar-wrap',
|
|
6100
|
+
'data-speed-day': entry.day,
|
|
6101
|
+
'data-speed-tps': String(tps),
|
|
6102
|
+
title: t('statsSpeedBarTitle', {
|
|
6103
|
+
date: entry.day,
|
|
6104
|
+
tps: tps.toFixed(1),
|
|
6105
|
+
tokens: formatTokensPrecise(entry.outputTokens),
|
|
6106
|
+
seconds: entry.seconds.toFixed(1),
|
|
6107
|
+
}),
|
|
6108
|
+
},
|
|
6109
|
+
h('div', {
|
|
6110
|
+
className: 'dsh-tokenlens-chart-bar',
|
|
6111
|
+
style: { height: `${height}%` },
|
|
6112
|
+
'aria-hidden': 'true',
|
|
6113
|
+
}),
|
|
6114
|
+
)
|
|
6115
|
+
})
|
|
6116
|
+
return h(
|
|
6117
|
+
'div',
|
|
6118
|
+
{ key: 'bars', className: 'dsh-tokenlens-chart-plot', 'data-speed-plot': 'true' },
|
|
6119
|
+
h('div', { key: 'row', className: 'dsh-tokenlens-chart-bars' }, bars),
|
|
6120
|
+
)
|
|
6121
|
+
})()
|
|
6122
|
+
return h(
|
|
6123
|
+
'div',
|
|
6124
|
+
{ key: 'speed', className: 'dsh-tokenlens-section', 'data-speed-section': 'true' },
|
|
6125
|
+
head,
|
|
6126
|
+
h('div', { key: 'hint', className: 'dsh-tokenlens-subtitle' }, t('statsSpeedHint')),
|
|
6127
|
+
body,
|
|
6128
|
+
)
|
|
6129
|
+
}
|
|
6130
|
+
|
|
5962
6131
|
function buildStatsDetails(state, ctx, t) {
|
|
5963
6132
|
const { stats, hasMonth } = ctx
|
|
5964
6133
|
if (stats === null) return null
|
|
@@ -7422,6 +7591,12 @@ window.__ModuleLoader__.load({
|
|
|
7422
7591
|
{ key: 'row-details', className: 'dsh-tokenlens-chart-layout-row' },
|
|
7423
7592
|
buildStatsDetails(state, statsCtx, t),
|
|
7424
7593
|
),
|
|
7594
|
+
// v1.7.0: the output-speed trend sits under 明细, as its own full-width row.
|
|
7595
|
+
h(
|
|
7596
|
+
'div',
|
|
7597
|
+
{ key: 'row-speed', className: 'dsh-tokenlens-chart-layout-row' },
|
|
7598
|
+
buildSpeedSection(state, statsCtx, t),
|
|
7599
|
+
),
|
|
7425
7600
|
)
|
|
7426
7601
|
const body = [buildManageButton(state, t), statsArea]
|
|
7427
7602
|
const displayVersion = currentPluginVersionOf(state)
|
package/index.js
CHANGED
|
@@ -84,7 +84,7 @@ export const inject = []
|
|
|
84
84
|
export const PLUGIN_NAME = 'dsh-tokenlens'
|
|
85
85
|
|
|
86
86
|
/** Plugin version; mirrors package.json (kept here so diagnostics need no fs read). */
|
|
87
|
-
export const PLUGIN_VERSION = '1.
|
|
87
|
+
export const PLUGIN_VERSION = '1.7.0'
|
|
88
88
|
|
|
89
89
|
/** Documented defaults; every field of the raw row config is optional. */
|
|
90
90
|
const DEFAULTS = {
|
package/lib/providers.mjs
CHANGED
|
@@ -549,14 +549,27 @@ export function parseCommandCode({ credits, subscription, whoami, usageSummary }
|
|
|
549
549
|
// meter jump while the account did not move, so it never does that here.
|
|
550
550
|
const summary = usageSummary !== null && typeof usageSummary === 'object' ? usageSummary : {}
|
|
551
551
|
const periodSpend = toNumber(summary.totalMonthlyCredits) ?? toNumber(summary.totalCredits)
|
|
552
|
+
// `/alpha/usage/summary` is an aggregate and LAGS the live windows by up to a
|
|
553
|
+
// few cents (observed $1.12 vs $1.28 live), which would understate the derived
|
|
554
|
+
// grant and therefore the percentage. When the summary is present it anchors
|
|
555
|
+
// the period, and a rolling window's `used` — always a subset of the period —
|
|
556
|
+
// can then only raise the estimate towards the truth, so the largest of them is
|
|
557
|
+
// taken. WITHOUT the summary the windows are NOT usable this way: a weekly
|
|
558
|
+
// window that does not cover the whole period would inflate the grant and drive
|
|
559
|
+
// the percentage towards zero, so that case stays `null`.
|
|
560
|
+
const liveWindowSpend =
|
|
561
|
+
periodSpend === null
|
|
562
|
+
? []
|
|
563
|
+
: [toNumber(limits.fiveHour?.used), toNumber(limits.weekly?.used)].filter((value) => value !== null)
|
|
564
|
+
const spend = periodSpend === null ? null : Math.max(periodSpend, ...liveWindowSpend)
|
|
552
565
|
const reportedGrant = toNumber(credit.monthlyCreditsGranted)
|
|
553
566
|
const granted =
|
|
554
567
|
reportedGrant !== null && reportedGrant > 0
|
|
555
568
|
? reportedGrant
|
|
556
|
-
: remaining !== null &&
|
|
557
|
-
? remaining +
|
|
569
|
+
: remaining !== null && spend !== null
|
|
570
|
+
? remaining + spend
|
|
558
571
|
: null
|
|
559
|
-
const monthlyUsed = granted !== null && remaining !== null ? Math.max(0, granted - remaining) :
|
|
572
|
+
const monthlyUsed = granted !== null && remaining !== null ? Math.max(0, granted - remaining) : spend
|
|
560
573
|
const monthlyPercent =
|
|
561
574
|
granted !== null && granted > 0 && monthlyUsed !== null
|
|
562
575
|
? normalizePercent((monthlyUsed / granted) * 100)
|
package/lib/usage-stats.mjs
CHANGED
|
@@ -61,7 +61,17 @@ const DECODES_CONCATENATED_FRAMES = (() => {
|
|
|
61
61
|
const SESSION_FILE = 'session.v4.jsonl.zstd'
|
|
62
62
|
|
|
63
63
|
/** Cache schema version; a mismatch discards the cache. */
|
|
64
|
-
const CACHE_VERSION =
|
|
64
|
+
const CACHE_VERSION = 6
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How many days the daily series (and therefore the 图表 mode and the
|
|
68
|
+
* 最近X天 provider window) may cover (v1.7.0).
|
|
69
|
+
*
|
|
70
|
+
* The wire used to carry 120 days, so a provider first seen months ago reported
|
|
71
|
+
* 最近90天 while every other figure and the charts described 30. One span now
|
|
72
|
+
* governs all of them.
|
|
73
|
+
*/
|
|
74
|
+
export const DAILY_SERIES_DAYS = 30
|
|
65
75
|
|
|
66
76
|
/** What the numbers are, by provider id (display fallback; config can override). */
|
|
67
77
|
const PROVIDER_LABELS = {
|
|
@@ -235,6 +245,11 @@ export function foldSession(records) {
|
|
|
235
245
|
const models = Object.create(null)
|
|
236
246
|
const hours = Object.create(null)
|
|
237
247
|
const workspaces = Object.create(null)
|
|
248
|
+
// v1.7.0: output-speed (TPS) samples. A step's model time is
|
|
249
|
+
// `assistant/message.time − step/start.time`: the step's tool calls are emitted
|
|
250
|
+
// AFTER the message, so they do not dilute it.
|
|
251
|
+
const speed = Object.create(null)
|
|
252
|
+
const stepStarts = new Map()
|
|
238
253
|
let latestTime = 0
|
|
239
254
|
let sessionCwd = null
|
|
240
255
|
const session = { seeded: false, subagent: false, createdAt: null, id: null, inheritedEvents: 0 }
|
|
@@ -267,6 +282,17 @@ export function foldSession(records) {
|
|
|
267
282
|
continue
|
|
268
283
|
}
|
|
269
284
|
if (boundarySeq !== null && Number.isSafeInteger(record.seq) && record.seq <= boundarySeq) continue
|
|
285
|
+
// v1.7.0: remember when each step's model request started, so the matching
|
|
286
|
+
// `assistant/message` can be turned into a duration.
|
|
287
|
+
if (record.type === 'step/start') {
|
|
288
|
+
const turn = record.data?.turn
|
|
289
|
+
const step = record.data?.step
|
|
290
|
+
const startedAt = typeof record.time === 'number' && Number.isFinite(record.time) ? record.time : null
|
|
291
|
+
if (startedAt !== null && Number.isSafeInteger(turn) && Number.isSafeInteger(step)) {
|
|
292
|
+
stepStarts.set(`${turn}:${step}`, startedAt)
|
|
293
|
+
}
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
270
296
|
const usage = usageOf(record)
|
|
271
297
|
if (usage === null) continue
|
|
272
298
|
const time = typeof record.time === 'number' && Number.isFinite(record.time) ? record.time : null
|
|
@@ -301,6 +327,23 @@ export function foldSession(records) {
|
|
|
301
327
|
const dayProvider = (dayBucket.providers[attributedProvider] ??= emptyBucket())
|
|
302
328
|
addBucket(dayProvider, usage)
|
|
303
329
|
addBucket((providers[attributedProvider] ??= emptyBucket()), usage)
|
|
330
|
+
// v1.7.0: one speed sample per completed step.
|
|
331
|
+
if (record.type === 'assistant/message' && usage.output > 0) {
|
|
332
|
+
const turn = record.data?.turn
|
|
333
|
+
const step = record.data?.step
|
|
334
|
+
const startedAt = Number.isSafeInteger(turn) && Number.isSafeInteger(step)
|
|
335
|
+
? stepStarts.get(`${turn}:${step}`)
|
|
336
|
+
: undefined
|
|
337
|
+
const elapsedMs = startedAt === undefined ? null : time - startedAt
|
|
338
|
+
if (elapsedMs !== null && elapsedMs > 0) {
|
|
339
|
+
const daySpeed = (speed[day] ??= { providers: Object.create(null) })
|
|
340
|
+
const slot = (daySpeed.providers[attributedProvider] ??= { output: 0, seconds: 0, steps: 0 })
|
|
341
|
+
slot.output += usage.output
|
|
342
|
+
slot.seconds += elapsedMs / 1000
|
|
343
|
+
slot.steps += 1
|
|
344
|
+
stepStarts.delete(`${turn}:${step}`)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
304
347
|
const modelKey = `${attributedProvider}/${attributedModel}`
|
|
305
348
|
const modelBucket = (models[modelKey] ??= {
|
|
306
349
|
provider: attributedProvider,
|
|
@@ -318,7 +361,7 @@ export function foldSession(records) {
|
|
|
318
361
|
if (Number(key) < cutoff) delete hours[key]
|
|
319
362
|
}
|
|
320
363
|
}
|
|
321
|
-
return { days, providers, models, hours, workspaces, session }
|
|
364
|
+
return { days, providers, models, hours, workspaces, session, speed }
|
|
322
365
|
}
|
|
323
366
|
|
|
324
367
|
/** Merge one file's folded contribution into an aggregate being built. */
|
|
@@ -329,6 +372,16 @@ function mergeFold(aggregate, fold) {
|
|
|
329
372
|
addBucket((target.providers[provider] ??= emptyBucket()), bucket)
|
|
330
373
|
}
|
|
331
374
|
}
|
|
375
|
+
// v1.7.0: merge the speed samples the same way as the token buckets.
|
|
376
|
+
for (const [day, daySpeed] of Object.entries(fold.speed ?? {})) {
|
|
377
|
+
const target = (aggregate.speed[day] ??= { providers: Object.create(null) })
|
|
378
|
+
for (const [provider, slot] of Object.entries(daySpeed.providers ?? {})) {
|
|
379
|
+
const into = (target.providers[provider] ??= { output: 0, seconds: 0, steps: 0 })
|
|
380
|
+
into.output += slot.output
|
|
381
|
+
into.seconds += slot.seconds
|
|
382
|
+
into.steps += slot.steps
|
|
383
|
+
}
|
|
384
|
+
}
|
|
332
385
|
for (const [provider, bucket] of Object.entries(fold.providers ?? {})) {
|
|
333
386
|
addBucket((aggregate.providers[provider] ??= emptyBucket()), bucket)
|
|
334
387
|
}
|
|
@@ -507,7 +560,18 @@ export function createUsageStats(input) {
|
|
|
507
560
|
const modelsAggregate = Object.create(null)
|
|
508
561
|
const hoursAggregate = Object.create(null)
|
|
509
562
|
const workspacesAggregate = Object.create(null)
|
|
563
|
+
// v1.7.0: `speedAggregate[day].providers[providerId] = {output, seconds, steps}`
|
|
564
|
+
const speedAggregate = Object.create(null)
|
|
510
565
|
for (const entry of Object.values(cache.files)) {
|
|
566
|
+
for (const [day, daySpeed] of Object.entries(entry.speed ?? {})) {
|
|
567
|
+
const target = (speedAggregate[day] ??= { providers: Object.create(null) })
|
|
568
|
+
for (const [provider, slot] of Object.entries(daySpeed.providers ?? {})) {
|
|
569
|
+
const into = (target.providers[provider] ??= { output: 0, seconds: 0, steps: 0 })
|
|
570
|
+
into.output += typeof slot.output === 'number' ? slot.output : 0
|
|
571
|
+
into.seconds += typeof slot.seconds === 'number' ? slot.seconds : 0
|
|
572
|
+
into.steps += typeof slot.steps === 'number' ? slot.steps : 0
|
|
573
|
+
}
|
|
574
|
+
}
|
|
511
575
|
for (const [day, dayBucket] of Object.entries(entry.days ?? {})) {
|
|
512
576
|
const target = (daysAggregate[day] ??= { providers: Object.create(null) })
|
|
513
577
|
for (const [provider, bucket] of Object.entries(dayBucket.providers ?? {})) {
|
|
@@ -725,6 +789,8 @@ export function createUsageStats(input) {
|
|
|
725
789
|
providers,
|
|
726
790
|
models: models.slice(0, 12),
|
|
727
791
|
daily,
|
|
792
|
+
// v1.7.0: raw per-day output-speed samples for the wire builder below.
|
|
793
|
+
speed: speedAggregate,
|
|
728
794
|
scan,
|
|
729
795
|
}
|
|
730
796
|
}
|
|
@@ -789,6 +855,8 @@ export function createUsageStats(input) {
|
|
|
789
855
|
models: fold.models,
|
|
790
856
|
hours: fold.hours,
|
|
791
857
|
workspaces: fold.workspaces,
|
|
858
|
+
// v1.7.0: per-day output-speed samples (CACHE_VERSION 6).
|
|
859
|
+
speed: fold.speed,
|
|
792
860
|
}
|
|
793
861
|
nextFiles[path] = entry
|
|
794
862
|
scanned += 1
|
|
@@ -1259,7 +1327,10 @@ export function createUsageStats(input) {
|
|
|
1259
1327
|
}
|
|
1260
1328
|
const daily = [...dailyMap.entries()]
|
|
1261
1329
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
1262
|
-
.
|
|
1330
|
+
// v1.7.0: 30 days, matching the 最近30天 bucket and the 最近X天 label. The
|
|
1331
|
+
// series used to carry 120 days, so the 图表 mode could plot a wider window
|
|
1332
|
+
// than the 数据 mode described.
|
|
1333
|
+
.slice(-DAILY_SERIES_DAYS)
|
|
1263
1334
|
.map(([day, entry]) => {
|
|
1264
1335
|
const providers = Object.create(null)
|
|
1265
1336
|
for (const [providerId, slot] of entry.providers) {
|
|
@@ -1292,15 +1363,77 @@ export function createUsageStats(input) {
|
|
|
1292
1363
|
return Math.max(1, Math.round((end - start) / dayMs) + 1)
|
|
1293
1364
|
}
|
|
1294
1365
|
const providers = [...providerMap.values()].sort((a, b) => b.allTime.total - a.allTime.total)
|
|
1366
|
+
/**
|
|
1367
|
+
* Build one output-speed series (v1.7.0).
|
|
1368
|
+
*
|
|
1369
|
+
* `tps` is output tokens over the summed model time of the steps that
|
|
1370
|
+
* produced them, so it describes generation speed rather than wall clock:
|
|
1371
|
+
* the tool calls of a step are emitted after its `assistant/message` and are
|
|
1372
|
+
* therefore excluded. A day with no positive duration is dropped instead of
|
|
1373
|
+
* being given an infinite rate.
|
|
1374
|
+
*/
|
|
1375
|
+
const speedSeries = (providerId) => {
|
|
1376
|
+
const source = dsh.speed ?? {}
|
|
1377
|
+
const byDay = []
|
|
1378
|
+
let output = 0
|
|
1379
|
+
let seconds = 0
|
|
1380
|
+
let steps = 0
|
|
1381
|
+
for (const day of Object.keys(source).sort()) {
|
|
1382
|
+
if (day < thirtyStart) continue
|
|
1383
|
+
let dayOutput = 0
|
|
1384
|
+
let daySeconds = 0
|
|
1385
|
+
let daySteps = 0
|
|
1386
|
+
for (const [id, slot] of Object.entries(source[day].providers ?? {})) {
|
|
1387
|
+
if (providerId !== null && id !== providerId) continue
|
|
1388
|
+
dayOutput += slot.output
|
|
1389
|
+
daySeconds += slot.seconds
|
|
1390
|
+
daySteps += slot.steps
|
|
1391
|
+
}
|
|
1392
|
+
if (daySeconds <= 0 || dayOutput <= 0) continue
|
|
1393
|
+
byDay.push({
|
|
1394
|
+
day,
|
|
1395
|
+
outputTokens: dayOutput,
|
|
1396
|
+
seconds: Math.round(daySeconds * 10) / 10,
|
|
1397
|
+
tps: Math.round((dayOutput / daySeconds) * 10) / 10,
|
|
1398
|
+
steps: daySteps,
|
|
1399
|
+
})
|
|
1400
|
+
output += dayOutput
|
|
1401
|
+
seconds += daySeconds
|
|
1402
|
+
steps += daySteps
|
|
1403
|
+
}
|
|
1404
|
+
return {
|
|
1405
|
+
byDay,
|
|
1406
|
+
total: {
|
|
1407
|
+
outputTokens: output,
|
|
1408
|
+
seconds: Math.round(seconds * 10) / 10,
|
|
1409
|
+
tps: seconds > 0 ? Math.round((output / seconds) * 10) / 10 : null,
|
|
1410
|
+
steps,
|
|
1411
|
+
},
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1295
1414
|
for (const provider of providers) {
|
|
1296
1415
|
const effectiveDay = firstDays.get(provider.providerId) ?? todayDay
|
|
1297
1416
|
provider.effectiveDay = effectiveDay
|
|
1298
|
-
|
|
1299
|
-
// A provider
|
|
1300
|
-
//
|
|
1301
|
-
|
|
1417
|
+
// v1.7.0: the 最近X天 window is capped at the same 30 days the charts and
|
|
1418
|
+
// the 最近30天 bucket use. A provider first seen 90 days ago used to report
|
|
1419
|
+
// 最近90天 while every other figure on the page covered 30.
|
|
1420
|
+
const fullDays = dayCount(effectiveDay, todayDay)
|
|
1421
|
+
const cappedDays = Math.min(DAILY_SERIES_DAYS, fullDays)
|
|
1422
|
+
provider.effectiveDays = cappedDays
|
|
1423
|
+
// The label and the numbers must describe the SAME span. When the real
|
|
1424
|
+
// history is longer than the cap, the since-effective bucket is therefore
|
|
1425
|
+
// the 30-day one rather than all-time (which would carry older records
|
|
1426
|
+
// under a 最近30天 label).
|
|
1427
|
+
provider.sinceEffective =
|
|
1428
|
+
cappedDays < fullDays ? cloneBucket(provider.last30Days) : cloneBucket(provider.allTime)
|
|
1429
|
+
// v1.7.0: each provider row carries its own output-speed series so the
|
|
1430
|
+
// chart can follow the same scope switch as the token sections.
|
|
1431
|
+
provider.speed = speedSeries(provider.providerId)
|
|
1302
1432
|
for (const harness of Object.values(provider.harnesses ?? {})) {
|
|
1303
|
-
harness.sinceEffective =
|
|
1433
|
+
harness.sinceEffective =
|
|
1434
|
+
cappedDays < fullDays && harness.last30Days !== undefined
|
|
1435
|
+
? cloneBucket(harness.last30Days)
|
|
1436
|
+
: cloneBucket(harness.allTime)
|
|
1304
1437
|
}
|
|
1305
1438
|
}
|
|
1306
1439
|
return {
|
|
@@ -1315,6 +1448,8 @@ export function createUsageStats(input) {
|
|
|
1315
1448
|
allTime,
|
|
1316
1449
|
workspaces,
|
|
1317
1450
|
providers,
|
|
1451
|
+
// v1.7.0: output-speed (TPS) series across every harness.
|
|
1452
|
+
speed: speedSeries(null),
|
|
1318
1453
|
harnesses,
|
|
1319
1454
|
models: dsh.models,
|
|
1320
1455
|
daily,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tokenlens",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "dsh-TokenLens (词元透镜) — provider-aware plan capacity plus local token statistics (today/7d/30d/all-time, by harness/provider/model/workspace) for DSH.",
|
|
6
6
|
"license": "MIT",
|