dsh-recent-usage-metrics 0.3.7 → 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,16 @@
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
+
1
14
  ## 0.3.7
2
15
 
3
16
  - `#` 表头的提示拆成三行:最近记录、三个状态图标、两种行颜色。原来挤成一句,
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
@@ -122,6 +122,11 @@ window.__ModuleLoader__.load({
122
122
  hintIndexRows: '行号:1–10 为已完成的最近记录',
123
123
  hintIndexGlyphs: '{bolt} 为模型输出中,{tools} 为工具调用中,{compact} 为压缩上下文中',
124
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: '该行请求配置变更,缓存前缀失效',
125
130
  hintTime: '请求发出的时刻(本地时间)',
126
131
  hintTtft: '从发出到收到第一个 token 的耗时',
127
132
  hintSpeed: '模型生成速度 = 输出 token ÷ 解码时长',
@@ -186,6 +191,10 @@ window.__ModuleLoader__.load({
186
191
  hintIndexRows: 'Row number: 1–10 are the most recent finished records',
187
192
  hintIndexGlyphs: '{bolt} is a model reply, {tools} is a tool call, {compact} is a context compaction',
188
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',
189
198
  hintTime: 'When the request was issued (local time)',
190
199
  hintTtft: 'Time from issuing the request to receiving its first token',
191
200
  hintSpeed: 'Generation speed = output tokens ÷ decode time',
@@ -1306,27 +1315,36 @@ window.__ModuleLoader__.load({
1306
1315
  }
1307
1316
 
1308
1317
  /**
1309
- * 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.
1310
1320
  *
1311
- * A `request/header` is written only when the routed model or tool set CHANGES, so its
1312
- * 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.
1313
1326
  *
1314
- * - inside a step's own seq window (from its `step/start` to the next one) → this request
1315
- * changed the routed configuration, and the header names the model it switched to;
1316
- * - absent → the step reused whatever was already routed, so it inherits the previous
1317
- * step's model.
1327
+ * The three signals are therefore separated:
1318
1328
  *
1319
- * Measured on a real 3202-step session: 22 steps carried a header and all 22 were genuine
1320
- * switches; 3180 inherited, of which every one with a known predecessor matched the model
1321
- * the reply later reported. The only gaps are a turn's first step with no header, where
1322
- * nothing earlier is known and the model stays null.
1323
- *
1324
- * 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".
1325
1337
  */
1326
1338
  function modelPlanFromStats(statsSnapshot, known) {
1327
1339
  const plan = new Map()
1328
1340
  const turns = statsSnapshot?.turns
1329
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 = []
1330
1348
  for (const turn of turns) {
1331
1349
  if (!Array.isArray(turn.steps)) continue
1332
1350
  const headers = []
@@ -1336,62 +1354,98 @@ window.__ModuleLoader__.load({
1336
1354
  const at = numberOrNull(event.time)
1337
1355
  if (at === null) continue
1338
1356
  if (typeof event.model !== 'string' && typeof event.provider !== 'string') continue
1339
- 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
+ })
1340
1363
  }
1341
1364
  headers.sort((left, right) => left.time - right.time)
1342
1365
  }
1343
- const starts = []
1366
+ const steps = []
1344
1367
  for (const step of turn.steps) {
1345
1368
  const at = numberOrNull(step.startTime)
1346
1369
  if (at === null || typeof step.step !== 'number') continue
1347
- 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
1348
1424
  }
1349
- if (starts.length === 0) continue
1350
- starts.sort((left, right) => left.at - right.at)
1351
1425
 
1352
1426
  /*
1353
- * The model currently in force, carried forward while the plan is built.
1354
- *
1355
- * A step whose header names a switch becomes the new carrier, so the step AFTER it
1356
- * inherits the switched-to model — not the one before the switch, which `known` alone
1357
- * 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.
1358
1429
  */
1359
- let inForce = null
1360
-
1361
- for (let i = 0; i < starts.length; i += 1) {
1362
- const key = `${turn.turn}:${starts[i].step}`
1363
- const from = starts[i].at
1364
- const until = i + 1 < starts.length ? starts[i + 1].at : Number.POSITIVE_INFINITY
1365
- let inside = null
1366
- for (const header of headers) {
1367
- if (header.time < from) continue
1368
- if (header.time >= until) break
1369
- inside = header
1370
- }
1371
- if (inside !== null) {
1372
- // The header was written for THIS request, so the model changed here.
1373
- inForce = { provider: inside.provider, model: inside.model }
1374
- plan.set(key, { provider: inside.provider, model: inside.model, switched: true, source: 'header' })
1375
- continue
1376
- }
1377
- /*
1378
- * No header: inherit. A finished reply outranks the carried model, because
1379
- * `assistant/message.source` is the authority for a step that has settled.
1380
- */
1381
- const settled = known === undefined ? undefined : known.get(key)
1382
- if (settled !== undefined && settled.model !== null) {
1383
- inForce = { provider: settled.provider, model: settled.model }
1384
- plan.set(key, { provider: settled.provider, model: settled.model, switched: false, source: 'reply' })
1385
- continue
1386
- }
1387
- if (inForce !== null) {
1388
- plan.set(key, { provider: inForce.provider, model: inForce.model, switched: false, source: 'inherit' })
1389
- }
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
1390
1443
  }
1391
1444
  }
1392
1445
  return plan
1393
1446
  }
1394
1447
 
1448
+
1395
1449
  /**
1396
1450
  * Whether any step started after this compaction finished.
1397
1451
  *
@@ -1464,6 +1518,14 @@ window.__ModuleLoader__.load({
1464
1518
  row.model = owner.model
1465
1519
  row.provider = owner.provider
1466
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
1467
1529
  }
1468
1530
  rows.push(row)
1469
1531
  }
@@ -1884,6 +1946,47 @@ window.__ModuleLoader__.load({
1884
1946
  )
1885
1947
  }
1886
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
+
1887
1990
  /**
1888
1991
  * The speed column's hint: which model served this row.
1889
1992
  *
@@ -3972,7 +4075,33 @@ window.__ModuleLoader__.load({
3972
4075
  */
3973
4076
  h(
3974
4077
  'span',
3975
- { 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
+ },
3976
4105
  entry.row.compaction === true
3977
4106
  ? h(CompactionIcon, {
3978
4107
  className:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recent-usage-metrics",
3
- "version": "0.3.7",
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",