dsh-recent-usage-metrics 0.3.6 → 0.4.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
@@ -1,3 +1,23 @@
1
+ ## 0.4.0
2
+
3
+ 把「模型切换」与「请求配置变更」分开——它们此前被当成同一件事,而后者远比前者常见。
4
+
5
+ - 判定改为:窗口内有 `request/header` **且本步模型与上一步不同**才算切换模型;上一步
6
+ 模型未知时以 header 为准。实测一个真实会话:27 个 header 里只有 7 次是真正的模型切换,
7
+ 其余 20 次是工具集变化(启用或禁用插件)。旧规则把这 20 次也标成切换模型,是错的。
8
+ - 新增「配置变更」信号:有 header 但模型未变。它的可见后果就是缓存变冷,所以显示在黄色
9
+ 那一行里。
10
+ - 序号列现在会在高亮行上给出说明:切换模型时写「该行切换模型至:X」;缓存过低时写
11
+ 「该行请求配置变更(工具集 66→65)」加「缓存命中 0.4%,低于 90%」。普通行的序号没有提示。
12
+ - 两个工具数只有都已知时才写成区间,否则只说配置发生变化,不编造数字。
13
+
14
+ ## 0.3.7
15
+
16
+ - `#` 表头的提示拆成三行:最近记录、三个状态图标、两种行颜色。原来挤成一句,
17
+ 三件事连在一起读起来费劲。
18
+ - 实时行不再显示总用时的工具提示。还在跑的请求尚未结算工具时间,提示只能给出
19
+ 「无工具调用」或一个不完整的数,对一个仍在进行的请求都是错的;行结算后再出现。
20
+
1
21
  ## 0.3.6
2
22
 
3
23
  - `#` 表头的提示加了一行颜色说明:黄色表示缓存命中过低,蓝色表示这一步切换了模型。
package/README.en.md CHANGED
@@ -165,6 +165,13 @@ Every drawer column and every header carries a hint:
165
165
  When both hold, the text takes the warn colour and the bar keeps the business colour. A
166
166
  compaction row never takes the cache highlight.
167
167
 
168
+ **A model switch and a configuration change are not the same thing.** A `request/header` only says
169
+ the routed configuration changed — enabling or disabling a plugin rewrites the tool list and writes
170
+ a header without touching the model. On a real session, only 7 of 27 headers were genuine model
171
+ switches. Blue therefore appears only when the model itself differs; a configuration change without
172
+ a model change shows up as a cold cache, coloured yellow, with the tool-list movement named in the
173
+ row-number hint.
174
+
168
175
  **Where the model name comes from:** a `request/header` inside the step's window means a switch
169
176
  and supplies the model; otherwise the previous step's model carries over, and a landed reply
170
177
  overrides both via `assistant/message.source`. The live row follows the same rule, so the model
package/README.md CHANGED
@@ -148,6 +148,11 @@ DSH 的极客级请求指标。它在 **设置 → 通用 → 性能与用量**
148
148
 
149
149
  两者同时成立时文字取警告色、色条保留业务蓝。压缩行不参与缓存高亮。
150
150
 
151
+ **「切换模型」与「配置变更」不是一回事。** 一个请求会写出 `request/header`,只说明路由配置
152
+ 变了——启用或禁用插件会改变工具集,同样会写 header,但模型没换。实测一个真实会话:27 个
153
+ header 里只有 7 次是真正的模型切换。所以蓝色只在模型确实不同时出现;模型没变但配置变了,
154
+ 它的后果就是缓存变冷,显示为黄色,并在序号提示里写明工具集的变化。
155
+
151
156
  **模型名的来源:** 本步窗口内的 `request/header` 判为切换,否则沿用上一步;回复落地后
152
157
  由 `assistant/message.source` 覆盖。实时行同样适用,所以生成期间就能看到模型名。
153
158
 
package/client/client.js CHANGED
@@ -114,9 +114,19 @@ window.__ModuleLoader__.load({
114
114
  hoursAgo: '{count} 小时前',
115
115
  // Column-header hints. `{bolt}` / `{tools}` / `{compact}` are replaced by the glyphs
116
116
  // themselves, which is why these are rendered by the tooltip component and not by title.
117
- hintIndex:
118
- '行号:1–10 为已完成的最近记录,{bolt} 为模型输出中,{tools} 为工具调用中,{compact} 为压缩上下文中',
117
+ /*
118
+ * The index hint is three lines: what the column holds, what the glyphs mean, then what
119
+ * the row colours mean. Splitting them keeps each line short enough to read at a glance
120
+ * instead of being one long sentence the eye has to parse.
121
+ */
122
+ hintIndexRows: '行号:1–10 为已完成的最近记录',
123
+ hintIndexGlyphs: '{bolt} 为模型输出中,{tools} 为工具调用中,{compact} 为压缩上下文中',
119
124
  hintColour: '整行黄色表示缓存命中过低,蓝色表示这一步切换了模型',
125
+ /* The row-number hint: only a highlighted row carries one, and it says why in numbers. */
126
+ rowSwitched: '该行切换模型至:{model}',
127
+ rowLowCache: '缓存命中 {rate},低于 {threshold}%',
128
+ rowConfigChanged: '该行请求配置变更(工具集 {from}→{to})',
129
+ rowConfigChangedUnknown: '该行请求配置变更,缓存前缀失效',
120
130
  hintTime: '请求发出的时刻(本地时间)',
121
131
  hintTtft: '从发出到收到第一个 token 的耗时',
122
132
  hintSpeed: '模型生成速度 = 输出 token ÷ 解码时长',
@@ -178,9 +188,13 @@ window.__ModuleLoader__.load({
178
188
  secondsAgo: '{count}s ago',
179
189
  minutesAgo: '{count}m ago',
180
190
  hoursAgo: '{count}h ago',
181
- hintIndex:
182
- 'Row number: 1–10 are the most recent finished records; {bolt} is a model reply, {tools} is a tool call, {compact} is a context compaction',
191
+ hintIndexRows: 'Row number: 1–10 are the most recent finished records',
192
+ hintIndexGlyphs: '{bolt} is a model reply, {tools} is a tool call, {compact} is a context compaction',
183
193
  hintColour: 'A yellow row means a low cache hit; a blue row means this step switched models',
194
+ rowSwitched: 'This row switched to {model}',
195
+ rowLowCache: 'Cache hit {rate}, below {threshold}%',
196
+ rowConfigChanged: 'Request configuration changed on this row (tools {from}→{to})',
197
+ rowConfigChangedUnknown: 'Request configuration changed on this row; the cached prefix was lost',
184
198
  hintTime: 'When the request was issued (local time)',
185
199
  hintTtft: 'Time from issuing the request to receiving its first token',
186
200
  hintSpeed: 'Generation speed = output tokens ÷ decode time',
@@ -1301,27 +1315,36 @@ window.__ModuleLoader__.load({
1301
1315
  }
1302
1316
 
1303
1317
  /**
1304
- * Which model a step used, and whether that request changed models.
1318
+ * Which model a step used, whether that request changed models, and whether it changed the
1319
+ * routed configuration at all.
1305
1320
  *
1306
- * A `request/header` is written only when the routed model or tool set CHANGES, so its
1307
- * placement is itself the signal:
1321
+ * A `request/header` is written when the routed configuration changes, and that includes far
1322
+ * more than the model: enabling or disabling a plugin changes the tool list, which rewrites
1323
+ * the request prefix and invalidates the provider's cache. Measured on a real session, 27
1324
+ * headers covered only 7 genuine model switches, so "a header exists" is NOT "the model
1325
+ * changed" — treating the two as one flagged every plugin toggle as a model switch.
1308
1326
  *
1309
- * - inside a step's own seq window (from its `step/start` to the next one) → this request
1310
- * changed the routed configuration, and the header names the model it switched to;
1311
- * - absent → the step reused whatever was already routed, so it inherits the previous
1312
- * step's model.
1327
+ * The three signals are therefore separated:
1313
1328
  *
1314
- * Measured on a real 3202-step session: 22 steps carried a header and all 22 were genuine
1315
- * switches; 3180 inherited, of which every one with a known predecessor matched the model
1316
- * the reply later reported. The only gaps are a turn's first step with no header, where
1317
- * nothing earlier is known and the model stays null.
1318
- *
1319
- * A finished reply overrides both, because `assistant/message.source` is the authority.
1329
+ * - `switched` — a header landed in the step's window AND the model differs from the one
1330
+ * before it. When the previous model is unknown the header is trusted, because there is
1331
+ * nothing to contradict it.
1332
+ * - `configChanged` — a header landed in the window but the model did NOT change, so the
1333
+ * change was elsewhere in the configuration (the tool list). Its visible consequence is a
1334
+ * cold cache, which is why it is reported beside the low-cache highlight.
1335
+ * - `toolCount` / `previousToolCount` — the tool-list sizes the two headers carried, so the
1336
+ * hint can say what actually moved rather than gesturing at "the configuration".
1320
1337
  */
1321
1338
  function modelPlanFromStats(statsSnapshot, known) {
1322
1339
  const plan = new Map()
1323
1340
  const turns = statsSnapshot?.turns
1324
1341
  if (!Array.isArray(turns)) return plan
1342
+ /*
1343
+ * The plan walks the session's steps in time order across turns, not turn by turn, because
1344
+ * "the previous model" has to compare against the step before it even when that step lives
1345
+ * in an earlier turn — a turn's first step is exactly where a switch is most likely.
1346
+ */
1347
+ const sequence = []
1325
1348
  for (const turn of turns) {
1326
1349
  if (!Array.isArray(turn.steps)) continue
1327
1350
  const headers = []
@@ -1331,62 +1354,98 @@ window.__ModuleLoader__.load({
1331
1354
  const at = numberOrNull(event.time)
1332
1355
  if (at === null) continue
1333
1356
  if (typeof event.model !== 'string' && typeof event.provider !== 'string') continue
1334
- headers.push({ time: at, provider: event.provider ?? null, model: event.model ?? null })
1357
+ headers.push({
1358
+ time: at,
1359
+ provider: event.provider ?? null,
1360
+ model: event.model ?? null,
1361
+ tools: numberOrNull(event.toolCount),
1362
+ })
1335
1363
  }
1336
1364
  headers.sort((left, right) => left.time - right.time)
1337
1365
  }
1338
- const starts = []
1366
+ const steps = []
1339
1367
  for (const step of turn.steps) {
1340
1368
  const at = numberOrNull(step.startTime)
1341
1369
  if (at === null || typeof step.step !== 'number') continue
1342
- starts.push({ at, step: step.step })
1370
+ steps.push({ at, step: step.step })
1371
+ }
1372
+ steps.sort((left, right) => left.at - right.at)
1373
+ for (const step of steps) sequence.push({ turn: turn.turn, step: step.step, at: step.at, headers })
1374
+ }
1375
+ sequence.sort((left, right) => left.at - right.at)
1376
+
1377
+ /** The model of the step before this one, or null when it is not known. */
1378
+ let previousModel = null
1379
+ let previousTools = null
1380
+ let inForce = null
1381
+
1382
+ for (let index = 0; index < sequence.length; index += 1) {
1383
+ const entry = sequence[index]
1384
+ const key = `${entry.turn}:${entry.step}`
1385
+ const nextAt = index + 1 < sequence.length ? sequence[index + 1].at : Number.POSITIVE_INFINITY
1386
+ let inside = null
1387
+ for (const header of entry.headers) {
1388
+ if (header.time < entry.at) continue
1389
+ if (header.time >= nextAt) break
1390
+ inside = header
1391
+ }
1392
+
1393
+ const settled = known === undefined ? undefined : known.get(key)
1394
+ const settledModel =
1395
+ settled !== undefined && settled.model !== null
1396
+ ? { provider: settled.provider, model: settled.model }
1397
+ : null
1398
+
1399
+ if (inside !== null) {
1400
+ const headerModel = { provider: inside.provider, model: inside.model }
1401
+ const changed =
1402
+ previousModel === null ||
1403
+ headerModel.model === null ||
1404
+ previousModel.model !== headerModel.model ||
1405
+ previousModel.provider !== headerModel.provider
1406
+ plan.set(key, {
1407
+ provider: inside.provider,
1408
+ model: inside.model,
1409
+ /*
1410
+ * A header alone proves only that the routed configuration changed. Saying "the model
1411
+ * switched" additionally requires that the model itself differs — and when the
1412
+ * previous model is unknown, the header is the only evidence, so it is trusted.
1413
+ */
1414
+ switched: changed,
1415
+ configChanged: !changed,
1416
+ toolCount: inside.tools,
1417
+ previousToolCount: previousTools,
1418
+ source: 'header',
1419
+ })
1420
+ inForce = headerModel
1421
+ previousModel = headerModel
1422
+ previousTools = inside.tools
1423
+ continue
1343
1424
  }
1344
- if (starts.length === 0) continue
1345
- starts.sort((left, right) => left.at - right.at)
1346
1425
 
1347
1426
  /*
1348
- * The model currently in force, carried forward while the plan is built.
1349
- *
1350
- * A step whose header names a switch becomes the new carrier, so the step AFTER it
1351
- * inherits the switched-to model — not the one before the switch, which `known` alone
1352
- * would give.
1427
+ * No header: the step reused what was already routed. A finished reply outranks the
1428
+ * carried model, because `assistant/message.source` is the authority for a settled step.
1353
1429
  */
1354
- let inForce = null
1355
-
1356
- for (let i = 0; i < starts.length; i += 1) {
1357
- const key = `${turn.turn}:${starts[i].step}`
1358
- const from = starts[i].at
1359
- const until = i + 1 < starts.length ? starts[i + 1].at : Number.POSITIVE_INFINITY
1360
- let inside = null
1361
- for (const header of headers) {
1362
- if (header.time < from) continue
1363
- if (header.time >= until) break
1364
- inside = header
1365
- }
1366
- if (inside !== null) {
1367
- // The header was written for THIS request, so the model changed here.
1368
- inForce = { provider: inside.provider, model: inside.model }
1369
- plan.set(key, { provider: inside.provider, model: inside.model, switched: true, source: 'header' })
1370
- continue
1371
- }
1372
- /*
1373
- * No header: inherit. A finished reply outranks the carried model, because
1374
- * `assistant/message.source` is the authority for a step that has settled.
1375
- */
1376
- const settled = known === undefined ? undefined : known.get(key)
1377
- if (settled !== undefined && settled.model !== null) {
1378
- inForce = { provider: settled.provider, model: settled.model }
1379
- plan.set(key, { provider: settled.provider, model: settled.model, switched: false, source: 'reply' })
1380
- continue
1381
- }
1382
- if (inForce !== null) {
1383
- plan.set(key, { provider: inForce.provider, model: inForce.model, switched: false, source: 'inherit' })
1384
- }
1430
+ const resolved = settledModel ?? inForce
1431
+ if (resolved !== null) {
1432
+ plan.set(key, {
1433
+ provider: resolved.provider,
1434
+ model: resolved.model,
1435
+ switched: false,
1436
+ configChanged: false,
1437
+ toolCount: null,
1438
+ previousToolCount: null,
1439
+ source: settledModel !== null ? 'reply' : 'inherit',
1440
+ })
1441
+ inForce = resolved
1442
+ previousModel = resolved
1385
1443
  }
1386
1444
  }
1387
1445
  return plan
1388
1446
  }
1389
1447
 
1448
+
1390
1449
  /**
1391
1450
  * Whether any step started after this compaction finished.
1392
1451
  *
@@ -1459,6 +1518,14 @@ window.__ModuleLoader__.load({
1459
1518
  row.model = owner.model
1460
1519
  row.provider = owner.provider
1461
1520
  row.switched = owner.switched === true
1521
+ /*
1522
+ * A configuration change that was not a model switch still rewrote the request prefix,
1523
+ * so it is worth naming when the cache came back cold: that is the difference between
1524
+ * "the account changed" and "the tool list moved".
1525
+ */
1526
+ row.configChanged = owner.configChanged === true
1527
+ row.toolCount = owner.toolCount ?? null
1528
+ row.previousToolCount = owner.previousToolCount ?? null
1462
1529
  }
1463
1530
  rows.push(row)
1464
1531
  }
@@ -1879,6 +1946,47 @@ window.__ModuleLoader__.load({
1879
1946
  )
1880
1947
  }
1881
1948
 
1949
+ /**
1950
+ * The row-number hint, or an empty list when the row is not highlighted.
1951
+ *
1952
+ * A highlighted row is worth explaining, and the number column is the one place that has room
1953
+ * without competing with a figure. Two independent reasons can apply, so the result is a list
1954
+ * of lines rather than a sentence:
1955
+ *
1956
+ * - the model switched, named outright;
1957
+ * - the cache came back cold, with the rate and the threshold it fell below.
1958
+ *
1959
+ * A cold row that ALSO changed its routed configuration names the tool-list movement, because
1960
+ * that is the usual cause of a lost prefix and the difference between "the account rotated"
1961
+ * and "a plugin was enabled". When the two tool counts are not both known, the line says only
1962
+ * that the configuration changed rather than inventing numbers.
1963
+ */
1964
+ function rowHintLines(row, t) {
1965
+ const lines = []
1966
+ if (row.compaction === true) return lines
1967
+ if (row.switched === true) {
1968
+ lines.push(t('rowSwitched', { model: modelHintText(row, t) }))
1969
+ }
1970
+ if (isLowCacheHit(row)) {
1971
+ if (row.configChanged === true) {
1972
+ const from = numberOrNull(row.previousToolCount)
1973
+ const to = numberOrNull(row.toolCount)
1974
+ lines.push(
1975
+ from !== null && to !== null
1976
+ ? t('rowConfigChanged', { from, to })
1977
+ : t('rowConfigChangedUnknown'),
1978
+ )
1979
+ }
1980
+ lines.push(
1981
+ t('rowLowCache', {
1982
+ rate: `${(row.cacheHit * 100).toFixed(1)}%`,
1983
+ threshold: Math.round(LOW_CACHE_HIT * 100),
1984
+ }),
1985
+ )
1986
+ }
1987
+ return lines
1988
+ }
1989
+
1882
1990
  /**
1883
1991
  * The speed column's hint: which model served this row.
1884
1992
  *
@@ -1961,29 +2069,33 @@ window.__ModuleLoader__.load({
1961
2069
  function indexHint(t) {
1962
2070
  const glyph = (Icon) => h(Icon, { className: 'dsh-rum-tip-icon' })
1963
2071
  /*
1964
- * Split on every placeholder first, then walk the pieces: a string piece is literal text,
1965
- * a placeholder becomes its glyph. Walking once over a token list is simpler to follow
1966
- * than nesting one split per placeholder.
2072
+ * Turn one sentence into elements, replacing each glyph placeholder with the glyph itself.
2073
+ * A single pass over a token list is simpler to follow than nesting one split per
2074
+ * placeholder, and it keeps the placeholders in whatever order a translation uses.
1967
2075
  */
1968
- const tokens = t('hintIndex').split(/(\{bolt\}|\{tools\}|\{compact\})/)
1969
- const nodes = []
1970
- for (let i = 0; i < tokens.length; i += 1) {
1971
- const token = tokens[i]
1972
- if (token === '') continue
1973
- if (token === '{bolt}') nodes.push(glyph(BoltIcon))
1974
- else if (token === '{tools}') nodes.push(glyph(ToolIcon))
1975
- else if (token === '{compact}') nodes.push(glyph(CompactionIcon))
1976
- else nodes.push(token)
2076
+ const withGlyphs = (text) => {
2077
+ const tokens = text.split(/(\{bolt\}|\{tools\}|\{compact\})/)
2078
+ const nodes = []
2079
+ for (const token of tokens) {
2080
+ if (token === '') continue
2081
+ if (token === '{bolt}') nodes.push(glyph(BoltIcon))
2082
+ else if (token === '{tools}') nodes.push(glyph(ToolIcon))
2083
+ else if (token === '{compact}') nodes.push(glyph(CompactionIcon))
2084
+ else nodes.push(token)
2085
+ }
2086
+ return nodes
1977
2087
  }
1978
2088
  /*
1979
- * The colour legend is its own line, with the two highlights drawn as swatches rather than
1980
- * named. A reader can then match a row to the legend by colour instead of translating a
1981
- * word, and the swatches use the same tokens the rows do, so the two cannot drift.
2089
+ * Three lines: what the column holds, what the glyphs mean, then what the row colours mean.
2090
+ * The colour line leads with two swatches instead of naming the hues, so a reader matches a
2091
+ * row against the legend by colour rather than by translating a word. The swatches take the
2092
+ * same tokens the rows take, so the two cannot drift apart.
1982
2093
  */
1983
2094
  return h(
1984
2095
  'span',
1985
2096
  { className: 'dsh-rum-tip-body' },
1986
- h('span', { className: 'dsh-rum-tip-line' }, ...nodes),
2097
+ h('span', { className: 'dsh-rum-tip-line' }, t('hintIndexRows')),
2098
+ h('span', { className: 'dsh-rum-tip-line' }, ...withGlyphs(t('hintIndexGlyphs'))),
1987
2099
  h(
1988
2100
  'span',
1989
2101
  { className: 'dsh-rum-tip-line' },
@@ -3912,15 +4024,17 @@ window.__ModuleLoader__.load({
3912
4024
  : speedText(live.speed, live.estimated === true, t),
3913
4025
  ),
3914
4026
  h('span', { className: 'dsh-rum-col', role: 'cell' }, tokensText(live.tokens, live.estimated === true, t)),
4027
+ /*
4028
+ * No hint on the live row's total.
4029
+ *
4030
+ * A running request has not reported its tool time yet, so the only
4031
+ * possible readings are "no tools" or a partial count — both would be
4032
+ * wrong about a request that is still going. The hint appears when the
4033
+ * row settles and the fold has the real figures.
4034
+ */
3915
4035
  h(
3916
4036
  'span',
3917
- {
3918
- className: 'dsh-rum-col',
3919
- role: 'cell',
3920
- /* Same hint as the numbered rows, so the meaning never changes. */
3921
- onMouseEnter: (event) => hint.show(toolHintText(live, t), event),
3922
- onMouseLeave: hint.hide,
3923
- },
4037
+ { className: 'dsh-rum-col', role: 'cell' },
3924
4038
  secondsText(
3925
4039
  live.compaction !== null && live.compaction !== undefined
3926
4040
  ? compactionSpan(live.compaction, now)
@@ -3961,7 +4075,33 @@ window.__ModuleLoader__.load({
3961
4075
  */
3962
4076
  h(
3963
4077
  'span',
3964
- { className: 'dsh-rum-index', role: 'cell' },
4078
+ {
4079
+ className: 'dsh-rum-index',
4080
+ role: 'cell',
4081
+ /*
4082
+ * Only a highlighted row explains itself here; an ordinary row's number
4083
+ * needs no gloss. The handler is attached unconditionally but returns
4084
+ * nothing to show when the list is empty, so hovering a plain row stays
4085
+ * silent.
4086
+ */
4087
+ onMouseEnter: (event) => {
4088
+ const lines = rowHintLines(entry.row, t)
4089
+ if (lines.length === 0) return
4090
+ hint.show(
4091
+ lines.length === 1
4092
+ ? lines[0]
4093
+ : h(
4094
+ 'span',
4095
+ { className: 'dsh-rum-tip-body' },
4096
+ ...lines.map((line, index) =>
4097
+ h('span', { className: 'dsh-rum-tip-line' }, line),
4098
+ ),
4099
+ ),
4100
+ event,
4101
+ )
4102
+ },
4103
+ onMouseLeave: hint.hide,
4104
+ },
3965
4105
  entry.row.compaction === true
3966
4106
  ? h(CompactionIcon, {
3967
4107
  className:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recent-usage-metrics",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "Geek level for the Performance & usage setting: a live reading of the newest request (first-token latency, speed, tokens, total, amortised) plus a drawer listing the last ten, all derived from the shipped Statistics view data",
5
5
  "repository": {
6
6
  "type": "git",