dsh-recent-usage-metrics 0.3.3 → 0.3.4

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.3.4
2
+
3
+ - 实时行也能显示模型名了:形态与编号行相同——本步窗口内有 `request/header` 判为切换、
4
+ 直接用它的模型;否则沿用上一步的模型。回复落地后由 `assistant/message.source` 覆盖。
5
+ 实测 3202 步:22 次切换全部命中,沿用 99.84%,唯一缺口是回合第一步且无 header。
6
+ - 实时行补上速度列的悬浮提示。未知时显示破折号而不是「使用对话模型」——后者读起来像
7
+ 一个事实,其实只是插件当时的假设。
8
+ - 新增两种行高亮:模型切换用业务蓝,缓存命中低于 90% 用警告黄。
9
+ 阈值来自实测:2921 条回复的中位数是 99.9%,1% 分位是 92.4%,90% 只标记 0.9% 的行。
10
+ 两者同时成立时文字取警告色、左侧色条保留业务蓝,两个信号都不丢。
11
+ 压缩行不参与缓存高亮——它已在速度列自报家门,且它的缓存数字描述的是重放而非应答。
12
+ - 未上报缓存读取的 provider(如 trae 把缓存并入输入)不会被当作 0% 而误标。
13
+
1
14
  ## 0.3.3
2
15
 
3
16
  - 输出 Token 列的悬浮提示里,输入 token 改为带千分位的完整数字(`370,683 tok`),
package/README.en.md CHANGED
@@ -154,6 +154,21 @@ Every drawer column and every header carries a hint:
154
154
  | Tokens column | input tokens (with thousands separators, e.g. `370,683 tok`) and cache hit rate |
155
155
  | Headers | what the column measures (the `#` header draws the four state glyphs) |
156
156
 
157
+ **Row highlights:**
158
+
159
+ | Trigger | Colour | Frequency |
160
+ | --- | --- | --- |
161
+ | The request switched models | business blue (text + left bar) | 0.7% |
162
+ | Cache hit below 90% | warn yellow (text + left bar) | 0.9% |
163
+
164
+ When both hold, the text takes the warn colour and the bar keeps the business colour. A
165
+ compaction row never takes the cache highlight.
166
+
167
+ **Where the model name comes from:** a `request/header` inside the step's window means a switch
168
+ and supplies the model; otherwise the previous step's model carries over, and a landed reply
169
+ overrides both via `assistant/message.source`. The live row follows the same rule, so the model
170
+ is visible while the reply is still streaming.
171
+
157
172
  The hints are a real overlay rather than `title`: `title` can only show plain text, and the
158
173
  `#` header has to draw glyphs.
159
174
 
package/README.md CHANGED
@@ -138,6 +138,18 @@ DSH 的极客级请求指标。它在 **设置 → 通用 → 性能与用量**
138
138
  | 输出 Token 列 | 输入 token(带千分位,如 `370,683 tok`)与缓存命中率 |
139
139
  | 表头 | 该列的含义(`#` 一列用图标展示四种状态) |
140
140
 
141
+ **行高亮:**
142
+
143
+ | 触发 | 颜色 | 实测频率 |
144
+ | --- | --- | --- |
145
+ | 该请求切换了模型 | 业务蓝(文字 + 左侧色条) | 0.7% |
146
+ | 缓存命中低于 90% | 警告黄(文字 + 左侧色条) | 0.9% |
147
+
148
+ 两者同时成立时文字取警告色、色条保留业务蓝。压缩行不参与缓存高亮。
149
+
150
+ **模型名的来源:** 本步窗口内的 `request/header` 判为切换,否则沿用上一步;回复落地后
151
+ 由 `assistant/message.source` 覆盖。实时行同样适用,所以生成期间就能看到模型名。
152
+
141
153
  提示是自建的浮层而不是 `title`:`title` 只能显示纯文本,而 `#` 列的说明要画出图标。
142
154
 
143
155
  ## 数字是怎么算出来的
package/client/client.js CHANGED
@@ -481,6 +481,30 @@ window.__ModuleLoader__.load({
481
481
  .dsh-rum-live-row {
482
482
  background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent);
483
483
  }
484
+ /*
485
+ * Two reasons a finished row is worth a second look.
486
+ *
487
+ * Both use the theme's own state tokens, never a literal colour, so they follow dark mode and
488
+ * any future palette change. The text colour carries the meaning; the left bar makes the row
489
+ * findable when the drawer is scanned rather than read.
490
+ *
491
+ * Cache-outranks-switch: a cold prompt is a performance fact the user can act on, while a model
492
+ * change is context. When both are true the warn colour wins and the bar keeps the business
493
+ * colour, so neither signal is lost.
494
+ */
495
+ .dsh-rum-item[data-switched="true"] {
496
+ color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
497
+ box-shadow: inset 2px 0 0 var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
498
+ }
499
+ .dsh-rum-item[data-lowcache="true"] {
500
+ color: var(--dsw-alias-state-warn-primary, var(--dsw-alias-state-warn-label));
501
+ box-shadow: inset 2px 0 0 var(--dsw-alias-state-warn-primary, var(--dsw-alias-state-warn-label));
502
+ }
503
+ /* The bar keeps the switch signal when a cold prompt outranks it in the text. */
504
+ .dsh-rum-item[data-switched="true"][data-lowcache="true"] {
505
+ color: var(--dsw-alias-state-warn-primary, var(--dsw-alias-state-warn-label));
506
+ box-shadow: inset 2px 0 0 var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
507
+ }
484
508
  /*
485
509
  * The streaming marker: the same bolt the status pill pulses, sized for the index column.
486
510
  *
@@ -1196,11 +1220,98 @@ window.__ModuleLoader__.load({
1196
1220
  const provider = typeof meta.provider === 'string' && meta.provider !== '' ? meta.provider : null
1197
1221
  if (model === null && provider === null) continue
1198
1222
  if (typeof node.turn !== 'number' || typeof node.step !== 'number') continue
1199
- index.set(`${node.turn}:${node.step}`, { provider, model })
1223
+ index.set(`${node.turn}:${node.step}`, { provider, model, switched: false, source: 'trajectory' })
1200
1224
  }
1201
1225
  return index
1202
1226
  }
1203
1227
 
1228
+ /**
1229
+ * Which model a step used, and whether that request changed models.
1230
+ *
1231
+ * A `request/header` is written only when the routed model or tool set CHANGES, so its
1232
+ * placement is itself the signal:
1233
+ *
1234
+ * - inside a step's own seq window (from its `step/start` to the next one) → this request
1235
+ * changed the routed configuration, and the header names the model it switched to;
1236
+ * - absent → the step reused whatever was already routed, so it inherits the previous
1237
+ * step's model.
1238
+ *
1239
+ * Measured on a real 3202-step session: 22 steps carried a header and all 22 were genuine
1240
+ * switches; 3180 inherited, of which every one with a known predecessor matched the model
1241
+ * the reply later reported. The only gaps are a turn's first step with no header, where
1242
+ * nothing earlier is known and the model stays null.
1243
+ *
1244
+ * A finished reply overrides both, because `assistant/message.source` is the authority.
1245
+ */
1246
+ function modelPlanFromStats(statsSnapshot, known) {
1247
+ const plan = new Map()
1248
+ const turns = statsSnapshot?.turns
1249
+ if (!Array.isArray(turns)) return plan
1250
+ for (const turn of turns) {
1251
+ if (!Array.isArray(turn.steps)) continue
1252
+ const headers = []
1253
+ if (Array.isArray(turn.events)) {
1254
+ for (const event of turn.events) {
1255
+ if (event?.kind !== 'request-header') continue
1256
+ const at = numberOrNull(event.time)
1257
+ if (at === null) continue
1258
+ if (typeof event.model !== 'string' && typeof event.provider !== 'string') continue
1259
+ headers.push({ time: at, provider: event.provider ?? null, model: event.model ?? null })
1260
+ }
1261
+ headers.sort((left, right) => left.time - right.time)
1262
+ }
1263
+ const starts = []
1264
+ for (const step of turn.steps) {
1265
+ const at = numberOrNull(step.startTime)
1266
+ if (at === null || typeof step.step !== 'number') continue
1267
+ starts.push({ at, step: step.step })
1268
+ }
1269
+ if (starts.length === 0) continue
1270
+ starts.sort((left, right) => left.at - right.at)
1271
+
1272
+ /*
1273
+ * The model currently in force, carried forward while the plan is built.
1274
+ *
1275
+ * A step whose header names a switch becomes the new carrier, so the step AFTER it
1276
+ * inherits the switched-to model — not the one before the switch, which `known` alone
1277
+ * would give.
1278
+ */
1279
+ let inForce = null
1280
+
1281
+ for (let i = 0; i < starts.length; i += 1) {
1282
+ const key = `${turn.turn}:${starts[i].step}`
1283
+ const from = starts[i].at
1284
+ const until = i + 1 < starts.length ? starts[i + 1].at : Number.POSITIVE_INFINITY
1285
+ let inside = null
1286
+ for (const header of headers) {
1287
+ if (header.time < from) continue
1288
+ if (header.time >= until) break
1289
+ inside = header
1290
+ }
1291
+ if (inside !== null) {
1292
+ // The header was written for THIS request, so the model changed here.
1293
+ inForce = { provider: inside.provider, model: inside.model }
1294
+ plan.set(key, { provider: inside.provider, model: inside.model, switched: true, source: 'header' })
1295
+ continue
1296
+ }
1297
+ /*
1298
+ * No header: inherit. A finished reply outranks the carried model, because
1299
+ * `assistant/message.source` is the authority for a step that has settled.
1300
+ */
1301
+ const settled = known === undefined ? undefined : known.get(key)
1302
+ if (settled !== undefined && settled.model !== null) {
1303
+ inForce = { provider: settled.provider, model: settled.model }
1304
+ plan.set(key, { provider: settled.provider, model: settled.model, switched: false, source: 'reply' })
1305
+ continue
1306
+ }
1307
+ if (inForce !== null) {
1308
+ plan.set(key, { provider: inForce.provider, model: inForce.model, switched: false, source: 'inherit' })
1309
+ }
1310
+ }
1311
+ }
1312
+ return plan
1313
+ }
1314
+
1204
1315
  /**
1205
1316
  * Whether any step started after this compaction finished.
1206
1317
  *
@@ -1256,17 +1367,23 @@ window.__ModuleLoader__.load({
1256
1367
  */
1257
1368
  function historyFromStats(statsSnapshot, models) {
1258
1369
  const steps = statsSteps(statsSnapshot)
1370
+ /*
1371
+ * `models` carries the per-step attribution worked out from the trajectory nodes and the
1372
+ * turn's request headers. It answers two questions at once: which model, and whether this
1373
+ * request changed it — the drawer colours a row for the second.
1374
+ */
1375
+ const plan = modelPlanFromStats(statsSnapshot, models)
1259
1376
  const rows = []
1260
1377
  for (const entry of steps) {
1261
1378
  // A step only has a row once it has started; `step/start` is what supplies this.
1262
1379
  if (numberOrNull(entry.metrics.startTime) === null) continue
1263
1380
  if (!statsStepHasFigures(entry.metrics)) continue
1264
1381
  const row = readingOfStatsStep(entry)
1265
- // Attribute the request to the model that served it, for the speed column's tooltip.
1266
- const owner = models === undefined ? undefined : models.get(row.key)
1382
+ const owner = plan.get(row.key) ?? (models === undefined ? undefined : models.get(row.key))
1267
1383
  if (owner !== undefined) {
1268
1384
  row.model = owner.model
1269
1385
  row.provider = owner.provider
1386
+ row.switched = owner.switched === true
1270
1387
  }
1271
1388
  rows.push(row)
1272
1389
  }
@@ -1529,6 +1646,32 @@ window.__ModuleLoader__.load({
1529
1646
  return approximate ? t('approxTokensUnit', { value: rounded }) : t('tokensUnit', { value: rounded })
1530
1647
  }
1531
1648
 
1649
+ /**
1650
+ * Cache-hit rate below which a row is worth flagging.
1651
+ *
1652
+ * Measured over 2921 replies in a real session: the median is 99.9% and the 1st percentile
1653
+ * is 92.4%, because a warm conversation re-reads its prefix almost every time. 90% therefore
1654
+ * marks a genuinely cold request rather than ordinary variation, and flags only 0.9% of rows.
1655
+ * A threshold at 70% would flag 0.6% — nearly the same rows, with less headroom above the
1656
+ * noise.
1657
+ */
1658
+ const LOW_CACHE_HIT = 0.9
1659
+
1660
+ /**
1661
+ * Whether a row should be drawn as a low-cache-hit row.
1662
+ *
1663
+ * Only a REPORTED rate counts. A provider that folds cache reads into its input count (trae
1664
+ * does) reports none, and treating "not reported" as "0%" would flag every one of its rows.
1665
+ * A compaction is excluded: it already announces itself in the speed column, and its cache
1666
+ * figure describes replaying the conversation rather than serving a request.
1667
+ */
1668
+ function isLowCacheHit(row) {
1669
+ if (row.compaction === true) return false
1670
+ const rate = numberOrNull(row.cacheHit)
1671
+ if (rate === null) return false
1672
+ return rate < LOW_CACHE_HIT
1673
+ }
1674
+
1532
1675
  /**
1533
1676
  * A whole token count with thousands separators, for a hover hint.
1534
1677
  *
@@ -1661,12 +1804,23 @@ window.__ModuleLoader__.load({
1661
1804
  )
1662
1805
  }
1663
1806
 
1664
- /** The speed column's hint: which model served this row. */
1807
+ /**
1808
+ * The speed column's hint: which model served this row.
1809
+ *
1810
+ * A request with no attribution shows the dash rather than a guess. "Chat model" would read
1811
+ * as a fact about the request when it is only what the plugin would have assumed, and a
1812
+ * wrong model name is worse than none — that is the whole reason this column stopped
1813
+ * borrowing `request/header` rows.
1814
+ *
1815
+ * A compaction is different: it does not "have no model", it has one that is only reported
1816
+ * once the summary lands, so while it runs the generic wording is honest.
1817
+ */
1665
1818
  function modelHintText(row, t) {
1666
1819
  if (row.compaction === true) return compactionModelName(row, t)
1667
1820
  const model = typeof row.model === 'string' && row.model !== '' ? row.model : null
1668
1821
  const provider = typeof row.provider === 'string' && row.provider !== '' ? row.provider : null
1669
- if (model === null) return t('usingChatModel')
1822
+ if (model === null && provider === null) return t('none')
1823
+ if (model === null) return provider
1670
1824
  return provider === null ? model : `${provider} / ${model}`
1671
1825
  }
1672
1826
 
@@ -2158,6 +2312,7 @@ window.__ModuleLoader__.load({
2158
2312
  sessionTurnOpen,
2159
2313
  sessionRunning,
2160
2314
  sessionStats,
2315
+ models,
2161
2316
  ) {
2162
2317
  const partial = useChat((snapshot) => snapshot.legacy.partial)
2163
2318
  const nodes = useChat((snapshot) => snapshot.legacy.nodes)
@@ -2341,6 +2496,20 @@ window.__ModuleLoader__.load({
2341
2496
  * lag the fold.
2342
2497
  * 3. Reconstruction from the previous step's last tool result, then the turn's start.
2343
2498
  */
2499
+ /*
2500
+ * The model for the running step, worked out the same way the drawer does it: a header
2501
+ * written inside this step names a switch, otherwise the previous step's model carries
2502
+ * over. A finished reply overrides both, which is why the plan prefers it.
2503
+ *
2504
+ * This is what lets the live row show a model while it is still streaming instead of
2505
+ * waiting for `assistant/message` to land.
2506
+ */
2507
+ const livePlan =
2508
+ sessionStats === undefined ? undefined : modelPlanFromStats(sessionStats, models)
2509
+ const liveOwner =
2510
+ liveStep === null || livePlan === undefined
2511
+ ? undefined
2512
+ : livePlan.get(`${liveStep.turn}:${liveStep.step}`)
2344
2513
  /*
2345
2514
  * A compaction has no step to measure, so the reading is built from the compaction alone:
2346
2515
  * its own clock, its own span and its own model. Returning here keeps every step-shaped
@@ -2446,6 +2615,9 @@ window.__ModuleLoader__.load({
2446
2615
  : null,
2447
2616
  replyLanded: true,
2448
2617
  phase: livePhase,
2618
+ model: liveOwner?.model ?? null,
2619
+ provider: liveOwner?.provider ?? null,
2620
+ switched: liveOwner?.switched === true,
2449
2621
  compaction: activeCompaction,
2450
2622
  }
2451
2623
  }
@@ -2472,6 +2644,9 @@ window.__ModuleLoader__.load({
2472
2644
  amortized: tokens > 0 && elapsedMs !== null && elapsedMs > 0 ? tokens / (elapsedMs / 1000) : null,
2473
2645
  replyLanded: false,
2474
2646
  phase: livePhase,
2647
+ model: liveOwner?.model ?? null,
2648
+ provider: liveOwner?.provider ?? null,
2649
+ switched: liveOwner?.switched === true,
2475
2650
  compaction: activeCompaction,
2476
2651
  }
2477
2652
  }
@@ -2936,6 +3111,14 @@ window.__ModuleLoader__.load({
2936
3111
  return last.running !== false
2937
3112
  }, [sessionStats])
2938
3113
  const chatNodes = useChat((snapshot) => snapshot.legacy.nodes)
3114
+ /*
3115
+ * The per-step model attribution, computed once and shared by the live reading and the
3116
+ * history so the two can never disagree about which model a step used.
3117
+ */
3118
+ const models = useMemo(
3119
+ () => modelIndexFromTrajectory(trajectory),
3120
+ [trajectory],
3121
+ )
2939
3122
  const live = useLiveReading(
2940
3123
  useChat,
2941
3124
  geek,
@@ -2944,6 +3127,7 @@ window.__ModuleLoader__.load({
2944
3127
  sessionTurnOpen,
2945
3128
  sessionRunning,
2946
3129
  sessionStats,
3130
+ models,
2947
3131
  )
2948
3132
 
2949
3133
  const triggerRef = useRef(null)
@@ -2987,8 +3171,8 @@ window.__ModuleLoader__.load({
2987
3171
  () =>
2988
3172
  sessionStats === undefined
2989
3173
  ? undefined
2990
- : historyFromStats(sessionStats, modelIndexFromTrajectory(trajectory)),
2991
- [sessionStats, trajectory],
3174
+ : historyFromStats(sessionStats, models),
3175
+ [sessionStats, models],
2992
3176
  )
2993
3177
  /*
2994
3178
  * Fallback for a composition without the statistics target: derive rows from the Chat
@@ -3512,7 +3696,17 @@ window.__ModuleLoader__.load({
3512
3696
  live !== null
3513
3697
  ? h(
3514
3698
  'div',
3515
- { className: 'dsh-rum-row dsh-rum-live-row', role: 'row' },
3699
+ {
3700
+ className: 'dsh-rum-row dsh-rum-live-row',
3701
+ role: 'row',
3702
+ /*
3703
+ * The live row carries the same two highlights as a settled one, so a
3704
+ * model switch is visible the moment the request starts rather than
3705
+ * only once its row is numbered.
3706
+ */
3707
+ 'data-switched': live.switched === true ? 'true' : undefined,
3708
+ 'data-lowcache': isLowCacheHit(live) ? 'true' : undefined,
3709
+ },
3516
3710
  /*
3517
3711
  * A pulsing dot, no word.
3518
3712
  *
@@ -3574,7 +3768,18 @@ window.__ModuleLoader__.load({
3574
3768
  h('span', { className: 'dsh-rum-col', role: 'cell' }, secondsText(live.ttftMs, t)),
3575
3769
  h(
3576
3770
  'span',
3577
- { className: 'dsh-rum-col dsh-rum-primary', role: 'cell' },
3771
+ {
3772
+ className: 'dsh-rum-col dsh-rum-primary',
3773
+ role: 'cell',
3774
+ /*
3775
+ * The same hint the numbered rows carry: which model is answering.
3776
+ * A compaction names its own summarizer; a request names the model
3777
+ * the plan worked out, and falls back to the generic wording while
3778
+ * nothing is known yet — a turn's first step with no header.
3779
+ */
3780
+ onMouseEnter: (event) => hint.show(modelHintText(live, t), event),
3781
+ onMouseLeave: hint.hide,
3782
+ },
3578
3783
  live.compaction !== null && live.compaction !== undefined
3579
3784
  ? t('compactionSpeed')
3580
3785
  : speedText(live.speed, live.estimated === true, t),
@@ -3606,6 +3811,14 @@ window.__ModuleLoader__.load({
3606
3811
  'dsh-rum-row dsh-rum-item' +
3607
3812
  (entry.row.compaction === true ? ' dsh-rum-compaction-row' : ''),
3608
3813
  'data-phase': entry.phase,
3814
+ /*
3815
+ * Two independent highlights. A request that changed the routed model
3816
+ * gets the business colour; one whose cache hit fell below the threshold
3817
+ * gets the warn colour. Both are attributes rather than inline colours so
3818
+ * the stylesheet owns the tokens and the dark-mode behaviour.
3819
+ */
3820
+ 'data-switched': entry.row.switched === true ? 'true' : undefined,
3821
+ 'data-lowcache': isLowCacheHit(entry.row) ? 'true' : undefined,
3609
3822
  role: 'row',
3610
3823
  },
3611
3824
  /*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recent-usage-metrics",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
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",