opencode-tokenwatch 0.2.0 → 0.3.1

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.
@@ -33,12 +33,20 @@ function renderKpiCards(data) {
33
33
  const s = data.summary;
34
34
  const hitRate = cacheHitRate(s.inputTokens, s.cacheRead);
35
35
  const hitRatePct = fmtPercent(hitRate);
36
- const avgTpsRaw = s.requestCount > 0 && data.perfSummary.length > 0
37
- ? data.perfSummary.reduce((sum, p) => sum + (p.avgTPS ?? 0) * p.requestCount, 0) /
38
- data.perfSummary.reduce((sum, p) => sum + p.requestCount, 0)
39
- : 0;
40
- const avgTps = avgTpsRaw ? avgTpsRaw.toFixed(1) : "—";
36
+ let tpsSum = 0, tpsReqs = 0;
37
+ for (const p of data.perfSummary) {
38
+ if (p.avgTPS != null && p.avgTPS > 0) {
39
+ tpsSum += p.avgTPS * p.requestCount;
40
+ tpsReqs += p.requestCount;
41
+ }
42
+ }
43
+ const avgTpsRaw = tpsReqs > 0 ? tpsSum / tpsReqs : 0;
44
+ const avgTps = tpsReqs > 0 ? avgTpsRaw.toFixed(1) : '—';
41
45
  const isHighCache = hitRate >= 0.5;
46
+ const errors = data.errors;
47
+ const errorRatePct = errors ? (errors.errorRate * 100).toFixed(1) + '%' : '—';
48
+ const errorColor = errors && errors.errorRate >= 0.05 ? 'var(--output)'
49
+ : errors && errors.errorRate > 0 ? 'var(--tps)' : 'var(--cache)';
42
50
  return `
43
51
  <div class="kpi-row">
44
52
  <div class="kpi-card">
@@ -61,6 +69,10 @@ function renderKpiCards(data) {
61
69
  <div class="kpi-label">Total Cost</div>
62
70
  <div class="kpi-value" style="color:var(--tps)">${fmtCost(s.totalCost)}</div>
63
71
  </div>
72
+ <div class="kpi-card">
73
+ <div class="kpi-label">Error Rate</div>
74
+ <div class="kpi-value" style="color:${errorColor}">${errorRatePct}</div>
75
+ </div>
64
76
  </div>`;
65
77
  }
66
78
  function renderModelChartInit(data) {
@@ -189,18 +201,16 @@ function renderModelChart(chart) {
189
201
  },
190
202
  {
191
203
  name: 'TPS',
192
- type: 'line',
204
+ type: 'scatter',
193
205
  yAxisIndex: 1,
194
206
  data: modelTps,
195
- smooth: true,
196
- symbol: 'circle',
197
- symbolSize: 6,
198
- lineStyle: { color: '#FFB800', width: 2 },
207
+ symbol: 'diamond',
208
+ symbolSize: function(val) { return val != null && val > 0 ? 13 : 0; },
199
209
  itemStyle: { color: '#FFB800' },
200
210
  label: {
201
211
  show: true,
202
212
  position: 'right',
203
- formatter: function(p) { return p.value != null ? p.value.toFixed(1) : ''; },
213
+ formatter: function(p) { return p.value != null && p.value > 0 ? p.value.toFixed(1) : ''; },
204
214
  color: '#FFB800', fontSize: 10
205
215
  }
206
216
  }
@@ -213,84 +223,112 @@ function renderModelChart(chart) {
213
223
  function renderScatterChartInit(data) {
214
224
  if (data.perfSummary.length === 0)
215
225
  return "";
216
- const providerColors = {
217
- opencode: "#00F593", deepseek: "#00D1FF", nvidia: "#B545FF", modelscope: "#FFB800",
218
- };
219
- const scatterData = data.perfSummary.map(p => {
220
- const costPer1K = p.totalInput + p.totalOutput > 0
221
- ? (p.totalCost / (p.totalInput + p.totalOutput)) * 1000
222
- : 0;
223
- return {
224
- name: p.model,
225
- provider: p.providerID,
226
- tps: p.avgTPS,
227
- value: [p.avgTTFT != null && p.avgTTFT > 0 ? p.avgTTFT : 0.01, costPer1K, p.requestCount]
228
- };
226
+ // TPS 降序排列(null TPS 放末尾),让最快的模型显示在最上方
227
+ const sorted = [...data.perfSummary].sort((a, b) => {
228
+ if (a.avgTPS == null && b.avgTPS == null)
229
+ return 0;
230
+ if (a.avgTPS == null)
231
+ return 1;
232
+ if (b.avgTPS == null)
233
+ return -1;
234
+ return b.avgTPS - a.avgTPS;
229
235
  });
236
+ const names = sorted.map(p => p.model);
237
+ const tpsValues = sorted.map(p => p.avgTPS ?? 0);
238
+ const ttftValues = sorted.map(p => p.avgTTFT ?? 0);
239
+ const costValues = sorted.map(p => {
240
+ const billable = p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite;
241
+ return billable > 0 ? (p.totalCost / billable) * 1000 : 0;
242
+ });
243
+ const hitRates = sorted.map(p => p.cacheHitRate ?? 0);
244
+ const reqCounts = sorted.map(p => p.requestCount);
230
245
  return `
231
- var scatterData = ${JSON.stringify(scatterData)};
232
- var providerColors = ${JSON.stringify(providerColors)};
246
+ var effNames = ${JSON.stringify(names)};
247
+ var effTps = ${JSON.stringify(tpsValues)};
248
+ var effTtft = ${JSON.stringify(ttftValues)};
249
+ var effCost = ${JSON.stringify(costValues)};
250
+ var effHit = ${JSON.stringify(hitRates)};
251
+ var effReq = ${JSON.stringify(reqCounts)};
233
252
 
234
253
  function initScatterChart() {
235
254
  var el = document.getElementById('scatter-chart');
236
255
  if (!el) return;
237
256
  var chart = echarts.init(el);
238
257
  window.scatterChart = chart;
258
+
259
+ // TPS 越高越绿,越低越紫,无数据为灰
260
+ var maxTps = Math.max.apply(null, effTps.filter(function(v){ return v > 0; })) || 1;
261
+ var barColors = effTps.map(function(v) {
262
+ if (v <= 0) return '#444455';
263
+ var r = v / maxTps;
264
+ if (r >= 0.8) return '#00F593';
265
+ if (r >= 0.5) return '#FFB800';
266
+ return '#B545FF';
267
+ });
268
+
239
269
  var option = {
240
270
  tooltip: {
271
+ trigger: 'axis',
272
+ axisPointer: { type: 'none' },
241
273
  formatter: function(params) {
242
- var d = params.data;
243
- return '<b>' + params.name + '</b><br/>' +
244
- 'Provider: ' + (d.provider || '\u2014') + '<br/>' +
245
- 'TTFT: ' + (d.value[0] != null ? d.value[0].toFixed(1) + 'ms' : '\u2014') + '<br/>' +
246
- 'TPS: ' + (d.tps != null ? d.tps.toFixed(1) : '\u2014') + '<br/>' +
247
- 'Cost/1K: $' + d.value[1].toFixed(4) + '<br/>' +
248
- 'Requests: ' + d.value[2];
274
+ var i = params[0].dataIndex;
275
+ var tps = effTps[i] > 0 ? effTps[i].toFixed(1) + ' tok/s' : '\u2014';
276
+ var ttft = effTtft[i] > 0 ? effTtft[i].toFixed(0) + ' ms' : '\u2014';
277
+ var cost = effCost[i] > 0 ? '$' + effCost[i].toFixed(4) + '/1K' : '\u2014';
278
+ var hit = effHit[i] > 0 ? effHit[i].toFixed(1) + '%' : '\u2014';
279
+ return '<b>' + effNames[i] + '</b><br/>' +
280
+ '\u25B6 TPS: ' + tps + '<br/>' +
281
+ '\u23F1 TTFT: ' + ttft + '<br/>' +
282
+ '\uD83D\uDCB0 Cost/1K: ' + cost + '<br/>' +
283
+ '\uD83D\uDCBE Cache Hit: ' + hit + '<br/>' +
284
+ 'Requests: ' + effReq[i];
249
285
  }
250
286
  },
251
- grid: { left: 80, right: 40, bottom: 60, top: 30 },
287
+ grid: { left: 20, right: 280, bottom: 30, top: 20, containLabel: true },
252
288
  xAxis: {
253
- type: 'log',
254
- name: 'TTFT (ms)',
255
- min: 0.01,
256
- nameTextStyle: { color: '#B0B0C0' },
257
- axisLabel: { color: '#B0B0C0' },
289
+ type: 'value',
290
+ name: 'Avg TPS (tokens / sec)',
291
+ nameTextStyle: { color: '#B0B0C0', fontSize: 11 },
292
+ axisLabel: { color: '#B0B0C0', formatter: function(v) { return v > 0 ? v.toFixed(0) : '0'; } },
258
293
  splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
259
294
  },
260
295
  yAxis: {
261
- type: 'value',
262
- name: 'Cost per 1K tokens',
263
- nameTextStyle: { color: '#B0B0C0' },
264
- axisLabel: { color: '#B0B0C0', formatter: function(v) { return '$' + v.toFixed(4); } },
265
- splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
296
+ type: 'category',
297
+ data: effNames,
298
+ inverse: true,
299
+ axisLabel: {
300
+ color: '#E0E0F0',
301
+ fontSize: 11,
302
+ formatter: function(v) { return v.length > 50 ? v.slice(0, 48) + '\u2026' : v; }
303
+ },
304
+ axisLine: { show: false },
305
+ axisTick: { show: false }
266
306
  },
267
307
  series: [{
268
- type: 'scatter',
269
- data: scatterData.map(function(d) {
270
- return {
271
- value: d.value,
272
- name: d.name,
273
- provider: d.provider,
274
- tps: d.tps,
275
- itemStyle: { color: providerColors[d.provider] || '#B545FF' }
276
- };
308
+ type: 'bar',
309
+ data: effTps.map(function(v, i) {
310
+ return { value: v > 0 ? v : 0.001, itemStyle: { color: barColors[i], borderRadius: [0, 4, 4, 0] } };
277
311
  }),
278
- symbolSize: function(val) {
279
- return Math.max(8, Math.min(40, Math.sqrt(val[2]) * 3));
280
- },
281
- itemStyle: { opacity: 0.8 },
312
+ barMaxWidth: 22,
282
313
  label: {
283
314
  show: true,
284
- formatter: function(p) { return p.name; },
285
315
  position: 'right',
286
- color: '#B0B0C0',
287
- fontSize: 10
316
+ color: '#E0E0F0',
317
+ fontSize: 10,
318
+ formatter: function(p) {
319
+ var i = p.dataIndex;
320
+ var parts = [effTps[i] > 0 ? effTps[i].toFixed(1) + ' t/s' : '\u2014'];
321
+ if (effTtft[i] > 0) parts.push('TTFT ' + effTtft[i].toFixed(0) + 'ms');
322
+ if (effCost[i] > 0) parts.push('\u0024' + effCost[i].toFixed(4) + '/1K');
323
+ return parts.join(' ');
324
+ }
288
325
  }
289
326
  }]
290
327
  };
291
328
  chart.setOption(option);
292
329
  chart.resize();
293
- }`;
330
+ }
331
+ `;
294
332
  }
295
333
  function providerBorderColor(provider) {
296
334
  const colors = { opencode: "#00F593", deepseek: "#00D1FF", nvidia: "#B545FF", modelscope: "#FFB800", };
@@ -300,14 +338,22 @@ function renderProviderCards(data) {
300
338
  return data.providers.map(p => {
301
339
  const modelCount = data.models.filter(m => m.provider === p.provider).length;
302
340
  const perfItems = data.perfSummary.filter(ps => ps.providerID === p.provider);
303
- const avgTtft = perfItems.length > 0
304
- ? perfItems.reduce((s, x) => s + (x.avgTTFT ?? 0) * x.requestCount, 0) /
305
- perfItems.reduce((s, x) => s + x.requestCount, 0)
306
- : null;
307
- const avgTps = perfItems.length > 0
308
- ? perfItems.reduce((s, x) => s + (x.avgTPS ?? 0) * x.requestCount, 0) /
309
- perfItems.reduce((s, x) => s + x.requestCount, 0)
310
- : null;
341
+ let ttftSum = 0, ttftReqs = 0;
342
+ for (const x of perfItems) {
343
+ if (x.avgTTFT != null && x.avgTTFT > 0) {
344
+ ttftSum += x.avgTTFT * x.requestCount;
345
+ ttftReqs += x.requestCount;
346
+ }
347
+ }
348
+ const avgTtft = ttftReqs > 0 ? ttftSum / ttftReqs : null;
349
+ let tpsSum = 0, tpsReqs = 0;
350
+ for (const x of perfItems) {
351
+ if (x.avgTPS != null && x.avgTPS > 0) {
352
+ tpsSum += x.avgTPS * x.requestCount;
353
+ tpsReqs += x.requestCount;
354
+ }
355
+ }
356
+ const avgTps = tpsReqs > 0 ? tpsSum / tpsReqs : null;
311
357
  return `
312
358
  <div class="provider-card" style="border-color:${providerBorderColor(p.provider)}">
313
359
  <div class="provider-name">${p.provider}</div>
@@ -326,6 +372,7 @@ function renderDataTable(data) {
326
372
  const hitColor = hitRate >= 0.85 ? 'var(--cache)' : hitRate >= 0.70 ? 'var(--tps)' : 'var(--output)';
327
373
  const perf = data.perfSummary.find(p => p.model === `${m.provider}/${m.model}`);
328
374
  const ttft = perf?.avgTTFT != null ? perf.avgTTFT.toFixed(0) + 'ms' : '—';
375
+ const p95ttft = perf?.p95TTFT != null ? perf.p95TTFT.toFixed(0) + 'ms' : '—';
329
376
  const tps = perf?.avgTPS != null ? perf.avgTPS.toFixed(1) : '—';
330
377
  return `<tr>
331
378
  <td>${m.model}</td>
@@ -337,6 +384,7 @@ function renderDataTable(data) {
337
384
  <td>${fmtTokens(m.cacheRead)}</td>
338
385
  <td style="color:${hitColor};font-weight:600">${hitRatePct}</td>
339
386
  <td>${ttft}</td>
387
+ <td style="color:var(--tps);font-size:0.85em">${p95ttft}</td>
340
388
  <td>${tps}</td>
341
389
  <td>${fmtCost(m.totalCost)}</td>
342
390
  </tr>`;
@@ -352,7 +400,8 @@ function renderDataTable(data) {
352
400
  <th>Output</th>
353
401
  <th>Cache</th>
354
402
  <th>Hit Rate</th>
355
- <th>TTFT</th>
403
+ <th>Avg TTFT</th>
404
+ <th>P95 TTFT</th>
356
405
  <th>TPS</th>
357
406
  <th>Cost</th>
358
407
  </tr>
@@ -360,6 +409,90 @@ function renderDataTable(data) {
360
409
  <tbody>${rows}</tbody>
361
410
  </table>`;
362
411
  }
412
+ /** 渲染 P50/P95/P99 延迟分位数表格 */
413
+ function renderPerfPercentileTable(data) {
414
+ if (data.perfSummary.length === 0)
415
+ return '';
416
+ const fmtMs = (v) => v != null ? v.toFixed(0) + 'ms' : '—';
417
+ const rows = data.perfSummary.map(p => {
418
+ const hitColor = p.cacheHitRate != null && p.cacheHitRate >= 85 ? 'var(--cache)'
419
+ : p.cacheHitRate != null && p.cacheHitRate >= 70 ? 'var(--tps)' : 'var(--output)';
420
+ return `<tr>
421
+ <td>${p.model}</td>
422
+ <td>${p.requestCount}</td>
423
+ <td>${fmtMs(p.avgTTFT)}</td>
424
+ <td>${fmtMs(p.p50TTFT)}</td>
425
+ <td>${fmtMs(p.p95TTFT)}</td>
426
+ <td>${fmtMs(p.p99TTFT)}</td>
427
+ <td>${fmtMs(p.avgLatency)}</td>
428
+ <td>${fmtMs(p.p50Latency)}</td>
429
+ <td>${fmtMs(p.p95Latency)}</td>
430
+ <td>${fmtMs(p.p99Latency)}</td>
431
+ <td style="color:${hitColor};font-weight:600">${p.cacheHitRate != null ? p.cacheHitRate.toFixed(1) + '%' : '—'}</td>
432
+ </tr>`;
433
+ }).join("\n");
434
+ return `
435
+ <section class="section">
436
+ <h2 class="section-title">Performance Latency Percentiles</h2>
437
+ <table class="data-table">
438
+ <thead>
439
+ <tr>
440
+ <th>Model</th>
441
+ <th>Req</th>
442
+ <th>Avg TTFT</th>
443
+ <th>P50 TTFT</th>
444
+ <th>P95 TTFT</th>
445
+ <th>P99 TTFT</th>
446
+ <th>Avg E2E</th>
447
+ <th>P50 E2E</th>
448
+ <th>P95 E2E</th>
449
+ <th>P99 E2E</th>
450
+ <th>Cache Hit</th>
451
+ </tr>
452
+ </thead>
453
+ <tbody>${rows}</tbody>
454
+ </table>
455
+ </section>`;
456
+ }
457
+ /** 渲染失败请求统计区域 */
458
+ function renderErrorStatsSection(data) {
459
+ const errors = data.errors;
460
+ if (!errors || errors.failedCount === 0)
461
+ return '';
462
+ const errorRatePct = (errors.errorRate * 100).toFixed(2) + '%';
463
+ const rateColor = errors.errorRate >= 0.05 ? 'var(--output)'
464
+ : errors.errorRate > 0 ? 'var(--tps)' : 'var(--cache)';
465
+ const rows = errors.byModel
466
+ .filter(m => m.failed > 0)
467
+ .map(m => {
468
+ const modelRate = m.total > 0 ? (m.failed / m.total * 100).toFixed(1) + '%' : '—';
469
+ return `<tr>
470
+ <td>${m.provider}</td>
471
+ <td>${m.model}</td>
472
+ <td>${m.total}</td>
473
+ <td style="color:var(--output)">${m.failed}</td>
474
+ <td style="color:var(--tps)">${m.total - m.failed}</td>
475
+ <td style="color:${errors.errorRate >= 0.05 ? 'var(--output)' : 'var(--tps)'}">${modelRate}</td>
476
+ </tr>`;
477
+ }).join('\n');
478
+ return `
479
+ <section class="section">
480
+ <h2 class="section-title">Failed Requests
481
+ <span style="margin-left:12px;font-size:0.85em;color:${rateColor}">
482
+ overall error rate: ${errorRatePct} (${errors.failedCount} failed / ${errors.successCount + errors.failedCount} total)
483
+ </span>
484
+ </h2>
485
+ <table class="data-table">
486
+ <thead>
487
+ <tr>
488
+ <th>Provider</th><th>Model</th><th>Total</th>
489
+ <th>Failed</th><th>Success</th><th>Error Rate</th>
490
+ </tr>
491
+ </thead>
492
+ <tbody>${rows}</tbody>
493
+ </table>
494
+ </section>`;
495
+ }
363
496
  function renderDailyTrendInit(data) {
364
497
  const days = data.daily.slice().reverse().map(d => d.day);
365
498
  const tokens = data.daily.slice().reverse().map(d => d.totalTokens);
@@ -511,6 +644,8 @@ export function generateUsageHtml(data) {
511
644
  const scatterChartJs = renderScatterChartInit(data);
512
645
  const providerStr = renderProviderCards(data);
513
646
  const tableStr = renderDataTable(data);
647
+ const perfPercentileStr = renderPerfPercentileTable(data);
648
+ const errorStatsStr = renderErrorStatsSection(data);
514
649
  const dailyChartJs = renderDailyTrendInit(data);
515
650
  const heatmapJs = renderHeatmapInit(data);
516
651
  const hasPerf = data.perfSummary.length > 0;
@@ -555,7 +690,7 @@ export function generateUsageHtml(data) {
555
690
  .header h1 span { color: var(--input); }
556
691
  .header .meta { font-size: 12px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; }
557
692
 
558
- .kpi-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 24px; }
693
+ .kpi-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 24px; }
559
694
  .kpi-card {
560
695
  background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
561
696
  padding: 16px; text-align: center;
@@ -656,6 +791,10 @@ export function generateUsageHtml(data) {
656
791
  ${tableStr}
657
792
  </div>
658
793
 
794
+ ${perfPercentileStr}
795
+
796
+ ${errorStatsStr}
797
+
659
798
  <div class="section">
660
799
  <div class="section-title">Efficiency vs Cost</div>
661
800
  ${hasPerf ? '<div class="chart-box" id="scatter-chart"></div>' : '<div class="empty-state">No performance data available for this period.</div>'}
package/dist/i18n.js CHANGED
@@ -14,6 +14,7 @@ const zh = {
14
14
  trendUp: "↑",
15
15
  trendDown: "↓",
16
16
  cache: "缓存",
17
+ lat: "延迟",
17
18
  performance: "性能",
18
19
  pricing: "Pricing",
19
20
  tokenDistribution: "Token分布",
@@ -40,8 +41,6 @@ const zh = {
40
41
  toolCall: "Tool调用",
41
42
  toolResult: "Tool结果",
42
43
  outputTokens: "输出",
43
- settings: "Settings",
44
- showCache: "显示缓存统计",
45
44
  showPerformance: "显示性能指标",
46
45
  showPricing: "显示模型定价",
47
46
  showTokenDistribution: "显示Token分布",
@@ -88,6 +87,7 @@ const en = {
88
87
  trendUp: "↑",
89
88
  trendDown: "↓",
90
89
  cache: "Cache",
90
+ lat: "Lat",
91
91
  performance: "Performance",
92
92
  pricing: "Pricing",
93
93
  tokenDistribution: "Token Distribution",
@@ -114,8 +114,6 @@ const en = {
114
114
  toolCall: "Tool Call",
115
115
  toolResult: "Tool Result",
116
116
  outputTokens: "Output",
117
- settings: "Settings",
118
- showCache: "Show Cache",
119
117
  showPerformance: "Show Performance",
120
118
  showPricing: "Show Pricing",
121
119
  showTokenDistribution: "Show Token Distribution",
@@ -41,14 +41,20 @@ interface MessageRemoveEvent {
41
41
  declare class PerfTracker {
42
42
  private firstPartTimes;
43
43
  private statsMap;
44
+ /** 原始样本串,用于分位数计算,不持久化 */
45
+ private ttftSamples;
46
+ private latencySamples;
44
47
  handlePartUpdated(event: PartEvent): void;
45
48
  handleMessageUpdated(event: MessageUpdateEvent): void;
46
49
  private appendLog;
47
50
  handleMessageRemoved(event: MessageRemoveEvent): void;
48
51
  private updateStats;
52
+ /** 计算有序数组的指定百分位数(线性插值法) */
53
+ private percentile;
49
54
  getSessionStats(): SessionPerfStats;
50
55
  readLogs(last?: number): LogEntry[];
51
56
  reset(): void;
57
+ loadSession(sessionID: string): void;
52
58
  }
53
59
  export declare function createPerfTracker(): PerfTracker;
54
60
  export type { PartEvent, PerfTracker };