opencode-tokenwatch 0.2.0 → 0.3.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.
@@ -37,30 +37,38 @@ function renderKpiCards(data) {
37
37
  ? data.perfSummary.reduce((sum, p) => sum + (p.avgTPS ?? 0) * p.requestCount, 0) /
38
38
  data.perfSummary.reduce((sum, p) => sum + p.requestCount, 0)
39
39
  : 0;
40
- const avgTps = avgTpsRaw ? avgTpsRaw.toFixed(1) : "";
40
+ const avgTps = avgTpsRaw ? avgTpsRaw.toFixed(1) : '';
41
41
  const isHighCache = hitRate >= 0.5;
42
- return `
43
- <div class="kpi-row">
44
- <div class="kpi-card">
45
- <div class="kpi-label">Total Tokens</div>
46
- <div class="kpi-value">${fmtTokens(s.totalTokens)}</div>
47
- </div>
48
- <div class="kpi-card${isHighCache ? ' kpi-glow' : ''}">
49
- <div class="kpi-label">Cache Hit Rate</div>
50
- <div class="kpi-value" style="color:var(--cache)">${hitRatePct}</div>
51
- </div>
52
- <div class="kpi-card">
53
- <div class="kpi-label">Avg TPS</div>
54
- <div class="kpi-value" style="color:var(--tps)">${avgTps}</div>
55
- </div>
56
- <div class="kpi-card">
57
- <div class="kpi-label">Requests</div>
58
- <div class="kpi-value">${s.requestCount}</div>
59
- </div>
60
- <div class="kpi-card">
61
- <div class="kpi-label">Total Cost</div>
62
- <div class="kpi-value" style="color:var(--tps)">${fmtCost(s.totalCost)}</div>
63
- </div>
42
+ const errors = data.errors;
43
+ const errorRatePct = errors ? (errors.errorRate * 100).toFixed(1) + '%' : '—';
44
+ const errorColor = errors && errors.errorRate >= 0.05 ? 'var(--output)'
45
+ : errors && errors.errorRate > 0 ? 'var(--tps)' : 'var(--cache)';
46
+ return `
47
+ <div class="kpi-row">
48
+ <div class="kpi-card">
49
+ <div class="kpi-label">Total Tokens</div>
50
+ <div class="kpi-value">${fmtTokens(s.totalTokens)}</div>
51
+ </div>
52
+ <div class="kpi-card${isHighCache ? ' kpi-glow' : ''}">
53
+ <div class="kpi-label">Cache Hit Rate</div>
54
+ <div class="kpi-value" style="color:var(--cache)">${hitRatePct}</div>
55
+ </div>
56
+ <div class="kpi-card">
57
+ <div class="kpi-label">Avg TPS</div>
58
+ <div class="kpi-value" style="color:var(--tps)">${avgTps}</div>
59
+ </div>
60
+ <div class="kpi-card">
61
+ <div class="kpi-label">Requests</div>
62
+ <div class="kpi-value">${s.requestCount}</div>
63
+ </div>
64
+ <div class="kpi-card">
65
+ <div class="kpi-label">Total Cost</div>
66
+ <div class="kpi-value" style="color:var(--tps)">${fmtCost(s.totalCost)}</div>
67
+ </div>
68
+ <div class="kpi-card">
69
+ <div class="kpi-label">Error Rate</div>
70
+ <div class="kpi-value" style="color:${errorColor}">${errorRatePct}</div>
71
+ </div>
64
72
  </div>`;
65
73
  }
66
74
  function renderModelChartInit(data) {
@@ -73,224 +81,250 @@ function renderModelChartInit(data) {
73
81
  const perf = data.perfSummary.find(p => p.model === `${m.provider}/${m.model}`);
74
82
  return perf?.avgTPS ?? null;
75
83
  });
76
- return `var modelNames = ${JSON.stringify(names)};
77
- var modelInput = ${JSON.stringify(inputData)};
78
- var modelOutput = ${JSON.stringify(outputData)};
79
- var modelCache = ${JSON.stringify(cacheData)};
80
- var modelTps = ${JSON.stringify(tpsData)};
81
-
82
- function initModelChart() {
83
- var el = document.getElementById('model-chart');
84
- if (!el) return;
85
- var chart = echarts.init(el);
86
- window.modelChart = chart;
87
- renderModelChart(chart);
88
- return chart;
89
- }
90
-
91
- function renderModelChart(chart) {
92
- var totals = modelInput.map(function(v, i) { return v + modelOutput[i] + modelCache[i]; });
93
- var option = {
94
- tooltip: {
95
- trigger: 'axis',
96
- axisPointer: { type: 'shadow' },
97
- formatter: function(params) {
98
- var html = '<b>' + params[0].axisValue + '</b><br/>';
99
- var total = 0;
100
- params.forEach(function(p) {
101
- if (p.seriesName !== 'TPS') {
102
- html += p.marker + ' ' + p.seriesName + ': ' + fmt(p.value) + '<br/>';
103
- total += p.value;
104
- }
105
- });
106
- html += 'Total: ' + fmt(total) + '<br/>';
107
- var tpsParam = params.find(function(p) { return p.seriesName === 'TPS'; });
108
- if (tpsParam && tpsParam.value != null) {
109
- html += tpsParam.marker + ' TPS: ' + tpsParam.value.toFixed(1) + '<br/>';
110
- }
111
- return html;
112
- }
113
- },
114
- legend: {
115
- data: ['Input', 'Output', 'Cache', 'TPS'],
116
- textStyle: { color: '#B0B0C0' },
117
- top: 5
118
- },
119
- grid: { left: 60, right: 60, bottom: 100, top: 50 },
120
- xAxis: {
121
- type: 'category',
122
- data: modelNames,
123
- axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
124
- axisLine: { lineStyle: { color: '#2A2A35' } }
125
- },
126
- yAxis: [
127
- {
128
- type: 'value',
129
- name: 'Tokens',
130
- nameTextStyle: { color: '#B0B0C0' },
131
- axisLabel: {
132
- color: '#B0B0C0',
133
- formatter: fmt
134
- },
135
- splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
136
- },
137
- {
138
- type: 'value',
139
- name: 'TPS',
140
- nameTextStyle: { color: '#FFB800' },
141
- axisLabel: { color: '#FFB800', formatter: function(v) { return v.toFixed(1); } },
142
- splitLine: { show: false }
143
- }
144
- ],
145
- series: [
146
- {
147
- name: 'Input',
148
- type: 'bar',
149
- stack: 'tokens',
150
- data: modelInput,
151
- itemStyle: { color: '#00D1FF' },
152
- barMaxWidth: 40
153
- },
154
- {
155
- name: 'Cache',
156
- type: 'bar',
157
- stack: 'tokens',
158
- data: modelCache,
159
- itemStyle: { color: '#00F593' },
160
- barMaxWidth: 40,
161
- label: {
162
- show: true,
163
- position: 'inside',
164
- formatter: function(p) {
165
- var cache = modelCache[p.dataIndex];
166
- var input = modelInput[p.dataIndex];
167
- if (cache === 0) return '';
168
- return (cache / (input + cache) * 100).toFixed(0) + '%';
169
- },
170
- color: '#fff', fontSize: 10, fontWeight: 'bold'
171
- }
172
- },
173
- {
174
- name: 'Output',
175
- type: 'bar',
176
- stack: 'tokens',
177
- data: modelOutput,
178
- itemStyle: { color: '#B545FF' },
179
- barMaxWidth: 40,
180
- label: {
181
- show: true,
182
- position: 'top',
183
- formatter: function(p) {
184
- var total = modelInput[p.dataIndex] + modelOutput[p.dataIndex] + modelCache[p.dataIndex];
185
- return total > 0 ? fmt(total) : '';
186
- },
187
- color: '#fff', fontSize: 10, fontWeight: 'bold'
188
- }
189
- },
190
- {
191
- name: 'TPS',
192
- type: 'line',
193
- yAxisIndex: 1,
194
- data: modelTps,
195
- smooth: true,
196
- symbol: 'circle',
197
- symbolSize: 6,
198
- lineStyle: { color: '#FFB800', width: 2 },
199
- itemStyle: { color: '#FFB800' },
200
- label: {
201
- show: true,
202
- position: 'right',
203
- formatter: function(p) { return p.value != null ? p.value.toFixed(1) : ''; },
204
- color: '#FFB800', fontSize: 10
205
- }
206
- }
207
- ]
208
- };
209
- chart.setOption(option);
210
- chart.resize();
84
+ return `var modelNames = ${JSON.stringify(names)};
85
+ var modelInput = ${JSON.stringify(inputData)};
86
+ var modelOutput = ${JSON.stringify(outputData)};
87
+ var modelCache = ${JSON.stringify(cacheData)};
88
+ var modelTps = ${JSON.stringify(tpsData)};
89
+
90
+ function initModelChart() {
91
+ var el = document.getElementById('model-chart');
92
+ if (!el) return;
93
+ var chart = echarts.init(el);
94
+ window.modelChart = chart;
95
+ renderModelChart(chart);
96
+ return chart;
97
+ }
98
+
99
+ function renderModelChart(chart) {
100
+ var totals = modelInput.map(function(v, i) { return v + modelOutput[i] + modelCache[i]; });
101
+ var option = {
102
+ tooltip: {
103
+ trigger: 'axis',
104
+ axisPointer: { type: 'shadow' },
105
+ formatter: function(params) {
106
+ var html = '<b>' + params[0].axisValue + '</b><br/>';
107
+ var total = 0;
108
+ params.forEach(function(p) {
109
+ if (p.seriesName !== 'TPS') {
110
+ html += p.marker + ' ' + p.seriesName + ': ' + fmt(p.value) + '<br/>';
111
+ total += p.value;
112
+ }
113
+ });
114
+ html += 'Total: ' + fmt(total) + '<br/>';
115
+ var tpsParam = params.find(function(p) { return p.seriesName === 'TPS'; });
116
+ if (tpsParam && tpsParam.value != null) {
117
+ html += tpsParam.marker + ' TPS: ' + tpsParam.value.toFixed(1) + '<br/>';
118
+ }
119
+ return html;
120
+ }
121
+ },
122
+ legend: {
123
+ data: ['Input', 'Output', 'Cache', 'TPS'],
124
+ textStyle: { color: '#B0B0C0' },
125
+ top: 5
126
+ },
127
+ grid: { left: 60, right: 60, bottom: 100, top: 50 },
128
+ xAxis: {
129
+ type: 'category',
130
+ data: modelNames,
131
+ axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
132
+ axisLine: { lineStyle: { color: '#2A2A35' } }
133
+ },
134
+ yAxis: [
135
+ {
136
+ type: 'value',
137
+ name: 'Tokens',
138
+ nameTextStyle: { color: '#B0B0C0' },
139
+ axisLabel: {
140
+ color: '#B0B0C0',
141
+ formatter: fmt
142
+ },
143
+ splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
144
+ },
145
+ {
146
+ type: 'value',
147
+ name: 'TPS',
148
+ nameTextStyle: { color: '#FFB800' },
149
+ axisLabel: { color: '#FFB800', formatter: function(v) { return v.toFixed(1); } },
150
+ splitLine: { show: false }
151
+ }
152
+ ],
153
+ series: [
154
+ {
155
+ name: 'Input',
156
+ type: 'bar',
157
+ stack: 'tokens',
158
+ data: modelInput,
159
+ itemStyle: { color: '#00D1FF' },
160
+ barMaxWidth: 40
161
+ },
162
+ {
163
+ name: 'Cache',
164
+ type: 'bar',
165
+ stack: 'tokens',
166
+ data: modelCache,
167
+ itemStyle: { color: '#00F593' },
168
+ barMaxWidth: 40,
169
+ label: {
170
+ show: true,
171
+ position: 'inside',
172
+ formatter: function(p) {
173
+ var cache = modelCache[p.dataIndex];
174
+ var input = modelInput[p.dataIndex];
175
+ if (cache === 0) return '';
176
+ return (cache / (input + cache) * 100).toFixed(0) + '%';
177
+ },
178
+ color: '#fff', fontSize: 10, fontWeight: 'bold'
179
+ }
180
+ },
181
+ {
182
+ name: 'Output',
183
+ type: 'bar',
184
+ stack: 'tokens',
185
+ data: modelOutput,
186
+ itemStyle: { color: '#B545FF' },
187
+ barMaxWidth: 40,
188
+ label: {
189
+ show: true,
190
+ position: 'top',
191
+ formatter: function(p) {
192
+ var total = modelInput[p.dataIndex] + modelOutput[p.dataIndex] + modelCache[p.dataIndex];
193
+ return total > 0 ? fmt(total) : '';
194
+ },
195
+ color: '#fff', fontSize: 10, fontWeight: 'bold'
196
+ }
197
+ },
198
+ {
199
+ name: 'TPS',
200
+ type: 'scatter',
201
+ yAxisIndex: 1,
202
+ data: modelTps,
203
+ symbol: 'diamond',
204
+ symbolSize: function(val) { return val != null && val > 0 ? 13 : 0; },
205
+ itemStyle: { color: '#FFB800' },
206
+ label: {
207
+ show: true,
208
+ position: 'right',
209
+ formatter: function(p) { return p.value != null && p.value > 0 ? p.value.toFixed(1) : ''; },
210
+ color: '#FFB800', fontSize: 10
211
+ }
212
+ }
213
+ ]
214
+ };
215
+ chart.setOption(option);
216
+ chart.resize();
211
217
  }`;
212
218
  }
213
219
  function renderScatterChartInit(data) {
214
220
  if (data.perfSummary.length === 0)
215
221
  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
- };
222
+ // TPS 降序排列(null TPS 放末尾),让最快的模型显示在最上方
223
+ const sorted = [...data.perfSummary].sort((a, b) => {
224
+ if (a.avgTPS == null && b.avgTPS == null)
225
+ return 0;
226
+ if (a.avgTPS == null)
227
+ return 1;
228
+ if (b.avgTPS == null)
229
+ return -1;
230
+ return b.avgTPS - a.avgTPS;
229
231
  });
230
- return `
231
- var scatterData = ${JSON.stringify(scatterData)};
232
- var providerColors = ${JSON.stringify(providerColors)};
233
-
234
- function initScatterChart() {
235
- var el = document.getElementById('scatter-chart');
236
- if (!el) return;
237
- var chart = echarts.init(el);
238
- window.scatterChart = chart;
239
- var option = {
240
- tooltip: {
241
- 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];
249
- }
250
- },
251
- grid: { left: 80, right: 40, bottom: 60, top: 30 },
252
- xAxis: {
253
- type: 'log',
254
- name: 'TTFT (ms)',
255
- min: 0.01,
256
- nameTextStyle: { color: '#B0B0C0' },
257
- axisLabel: { color: '#B0B0C0' },
258
- splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
259
- },
260
- 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' } }
266
- },
267
- 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
- };
277
- }),
278
- symbolSize: function(val) {
279
- return Math.max(8, Math.min(40, Math.sqrt(val[2]) * 3));
280
- },
281
- itemStyle: { opacity: 0.8 },
282
- label: {
283
- show: true,
284
- formatter: function(p) { return p.name; },
285
- position: 'right',
286
- color: '#B0B0C0',
287
- fontSize: 10
288
- }
289
- }]
290
- };
291
- chart.setOption(option);
292
- chart.resize();
293
- }`;
232
+ const names = sorted.map(p => p.model);
233
+ const tpsValues = sorted.map(p => p.avgTPS ?? 0);
234
+ const ttftValues = sorted.map(p => p.avgTTFT ?? 0);
235
+ const costValues = sorted.map(p => {
236
+ const billable = p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite;
237
+ return billable > 0 ? (p.totalCost / billable) * 1000 : 0;
238
+ });
239
+ const hitRates = sorted.map(p => p.cacheHitRate ?? 0);
240
+ const reqCounts = sorted.map(p => p.requestCount);
241
+ return `
242
+ var effNames = ${JSON.stringify(names)};
243
+ var effTps = ${JSON.stringify(tpsValues)};
244
+ var effTtft = ${JSON.stringify(ttftValues)};
245
+ var effCost = ${JSON.stringify(costValues)};
246
+ var effHit = ${JSON.stringify(hitRates)};
247
+ var effReq = ${JSON.stringify(reqCounts)};
248
+
249
+ function initScatterChart() {
250
+ var el = document.getElementById('scatter-chart');
251
+ if (!el) return;
252
+ var chart = echarts.init(el);
253
+ window.scatterChart = chart;
254
+
255
+ // TPS 越高越绿,越低越紫,无数据为灰
256
+ var maxTps = Math.max.apply(null, effTps.filter(function(v){ return v > 0; })) || 1;
257
+ var barColors = effTps.map(function(v) {
258
+ if (v <= 0) return '#444455';
259
+ var r = v / maxTps;
260
+ if (r >= 0.8) return '#00F593';
261
+ if (r >= 0.5) return '#FFB800';
262
+ return '#B545FF';
263
+ });
264
+
265
+ var option = {
266
+ tooltip: {
267
+ trigger: 'axis',
268
+ axisPointer: { type: 'none' },
269
+ formatter: function(params) {
270
+ var i = params[0].dataIndex;
271
+ var tps = effTps[i] > 0 ? effTps[i].toFixed(1) + ' tok/s' : '\u2014';
272
+ var ttft = effTtft[i] > 0 ? effTtft[i].toFixed(0) + ' ms' : '\u2014';
273
+ var cost = effCost[i] > 0 ? '$' + effCost[i].toFixed(4) + '/1K' : '\u2014';
274
+ var hit = effHit[i] > 0 ? effHit[i].toFixed(1) + '%' : '\u2014';
275
+ return '<b>' + effNames[i] + '</b><br/>' +
276
+ '\u25B6 TPS: ' + tps + '<br/>' +
277
+ '\u23F1 TTFT: ' + ttft + '<br/>' +
278
+ '\uD83D\uDCB0 Cost/1K: ' + cost + '<br/>' +
279
+ '\uD83D\uDCBE Cache Hit: ' + hit + '<br/>' +
280
+ 'Requests: ' + effReq[i];
281
+ }
282
+ },
283
+ grid: { left: 20, right: 240, bottom: 30, top: 20, containLabel: true },
284
+ xAxis: {
285
+ type: 'value',
286
+ name: 'Avg TPS (tokens / sec)',
287
+ nameTextStyle: { color: '#B0B0C0', fontSize: 11 },
288
+ axisLabel: { color: '#B0B0C0', formatter: function(v) { return v > 0 ? v.toFixed(0) : '0'; } },
289
+ splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
290
+ },
291
+ yAxis: {
292
+ type: 'category',
293
+ data: effNames,
294
+ inverse: true,
295
+ axisLabel: {
296
+ color: '#E0E0F0',
297
+ fontSize: 11,
298
+ formatter: function(v) { return v.length > 26 ? v.slice(0, 24) + '\u2026' : v; }
299
+ },
300
+ axisLine: { show: false },
301
+ axisTick: { show: false }
302
+ },
303
+ series: [{
304
+ type: 'bar',
305
+ data: effTps.map(function(v, i) {
306
+ return { value: v > 0 ? v : 0.001, itemStyle: { color: barColors[i], borderRadius: [0, 4, 4, 0] } };
307
+ }),
308
+ barMaxWidth: 22,
309
+ label: {
310
+ show: true,
311
+ position: 'right',
312
+ color: '#E0E0F0',
313
+ fontSize: 10,
314
+ formatter: function(p) {
315
+ var i = p.dataIndex;
316
+ var parts = [effTps[i] > 0 ? effTps[i].toFixed(1) + ' t/s' : '\u2014'];
317
+ if (effTtft[i] > 0) parts.push('TTFT ' + effTtft[i].toFixed(0) + 'ms');
318
+ if (effCost[i] > 0) parts.push('\u0024' + effCost[i].toFixed(4) + '/1K');
319
+ return parts.join(' ');
320
+ }
321
+ }
322
+ }]
323
+ };
324
+ chart.setOption(option);
325
+ chart.resize();
326
+ }
327
+ `;
294
328
  }
295
329
  function providerBorderColor(provider) {
296
330
  const colors = { opencode: "#00F593", deepseek: "#00D1FF", nvidia: "#B545FF", modelscope: "#FFB800", };
@@ -308,14 +342,14 @@ function renderProviderCards(data) {
308
342
  ? perfItems.reduce((s, x) => s + (x.avgTPS ?? 0) * x.requestCount, 0) /
309
343
  perfItems.reduce((s, x) => s + x.requestCount, 0)
310
344
  : null;
311
- return `
312
- <div class="provider-card" style="border-color:${providerBorderColor(p.provider)}">
313
- <div class="provider-name">${p.provider}</div>
314
- <div class="provider-stat"><span class="stat-label">Tokens</span><span>${fmtTokens(p.totalTokens)}</span></div>
315
- <div class="provider-stat"><span class="stat-label">Cost</span><span>${fmtCost(p.totalCost)}</span></div>
316
- <div class="provider-stat"><span class="stat-label">Avg TTFT</span><span>${avgTtft != null ? avgTtft.toFixed(0) + 'ms' : '—'}</span></div>
317
- <div class="provider-stat"><span class="stat-label">Avg TPS</span><span>${avgTps != null ? avgTps.toFixed(1) : '—'}</span></div>
318
- <div class="provider-stat"><span class="stat-label">Models</span><span>${modelCount}</span></div>
345
+ return `
346
+ <div class="provider-card" style="border-color:${providerBorderColor(p.provider)}">
347
+ <div class="provider-name">${p.provider}</div>
348
+ <div class="provider-stat"><span class="stat-label">Tokens</span><span>${fmtTokens(p.totalTokens)}</span></div>
349
+ <div class="provider-stat"><span class="stat-label">Cost</span><span>${fmtCost(p.totalCost)}</span></div>
350
+ <div class="provider-stat"><span class="stat-label">Avg TTFT</span><span>${avgTtft != null ? avgTtft.toFixed(0) + 'ms' : '—'}</span></div>
351
+ <div class="provider-stat"><span class="stat-label">Avg TPS</span><span>${avgTps != null ? avgTps.toFixed(1) : '—'}</span></div>
352
+ <div class="provider-stat"><span class="stat-label">Models</span><span>${modelCount}</span></div>
319
353
  </div>`;
320
354
  }).join("\n");
321
355
  }
@@ -326,126 +360,213 @@ function renderDataTable(data) {
326
360
  const hitColor = hitRate >= 0.85 ? 'var(--cache)' : hitRate >= 0.70 ? 'var(--tps)' : 'var(--output)';
327
361
  const perf = data.perfSummary.find(p => p.model === `${m.provider}/${m.model}`);
328
362
  const ttft = perf?.avgTTFT != null ? perf.avgTTFT.toFixed(0) + 'ms' : '—';
363
+ const p95ttft = perf?.p95TTFT != null ? perf.p95TTFT.toFixed(0) + 'ms' : '—';
329
364
  const tps = perf?.avgTPS != null ? perf.avgTPS.toFixed(1) : '—';
330
- return `<tr>
331
- <td>${m.model}</td>
332
- <td>${m.provider}</td>
333
- <td>${m.requests}</td>
334
- <td>${fmtTokens(m.totalTokens)}</td>
335
- <td>${fmtTokens(m.inputTokens)}</td>
336
- <td>${fmtTokens(m.outputTokens)}</td>
337
- <td>${fmtTokens(m.cacheRead)}</td>
338
- <td style="color:${hitColor};font-weight:600">${hitRatePct}</td>
339
- <td>${ttft}</td>
340
- <td>${tps}</td>
341
- <td>${fmtCost(m.totalCost)}</td>
365
+ return `<tr>
366
+ <td>${m.model}</td>
367
+ <td>${m.provider}</td>
368
+ <td>${m.requests}</td>
369
+ <td>${fmtTokens(m.totalTokens)}</td>
370
+ <td>${fmtTokens(m.inputTokens)}</td>
371
+ <td>${fmtTokens(m.outputTokens)}</td>
372
+ <td>${fmtTokens(m.cacheRead)}</td>
373
+ <td style="color:${hitColor};font-weight:600">${hitRatePct}</td>
374
+ <td>${ttft}</td>
375
+ <td style="color:var(--tps);font-size:0.85em">${p95ttft}</td>
376
+ <td>${tps}</td>
377
+ <td>${fmtCost(m.totalCost)}</td>
342
378
  </tr>`;
343
379
  }).join("\n");
344
- return `<table class="data-table">
345
- <thead>
346
- <tr>
347
- <th>Model</th>
348
- <th>Provider</th>
349
- <th>Req</th>
350
- <th>Total</th>
351
- <th>Input</th>
352
- <th>Output</th>
353
- <th>Cache</th>
354
- <th>Hit Rate</th>
355
- <th>TTFT</th>
356
- <th>TPS</th>
357
- <th>Cost</th>
358
- </tr>
359
- </thead>
360
- <tbody>${rows}</tbody>
380
+ return `<table class="data-table">
381
+ <thead>
382
+ <tr>
383
+ <th>Model</th>
384
+ <th>Provider</th>
385
+ <th>Req</th>
386
+ <th>Total</th>
387
+ <th>Input</th>
388
+ <th>Output</th>
389
+ <th>Cache</th>
390
+ <th>Hit Rate</th>
391
+ <th>Avg TTFT</th>
392
+ <th>P95 TTFT</th>
393
+ <th>TPS</th>
394
+ <th>Cost</th>
395
+ </tr>
396
+ </thead>
397
+ <tbody>${rows}</tbody>
361
398
  </table>`;
362
399
  }
400
+ /** 渲染 P50/P95/P99 延迟分位数表格 */
401
+ function renderPerfPercentileTable(data) {
402
+ if (data.perfSummary.length === 0)
403
+ return '';
404
+ const fmtMs = (v) => v != null ? v.toFixed(0) + 'ms' : '—';
405
+ const rows = data.perfSummary.map(p => {
406
+ const hitColor = p.cacheHitRate != null && p.cacheHitRate >= 85 ? 'var(--cache)'
407
+ : p.cacheHitRate != null && p.cacheHitRate >= 70 ? 'var(--tps)' : 'var(--output)';
408
+ return `<tr>
409
+ <td>${p.model}</td>
410
+ <td>${p.requestCount}</td>
411
+ <td>${fmtMs(p.avgTTFT)}</td>
412
+ <td>${fmtMs(p.p50TTFT)}</td>
413
+ <td>${fmtMs(p.p95TTFT)}</td>
414
+ <td>${fmtMs(p.p99TTFT)}</td>
415
+ <td>${fmtMs(p.avgLatency)}</td>
416
+ <td>${fmtMs(p.p50Latency)}</td>
417
+ <td>${fmtMs(p.p95Latency)}</td>
418
+ <td>${fmtMs(p.p99Latency)}</td>
419
+ <td style="color:${hitColor};font-weight:600">${p.cacheHitRate != null ? p.cacheHitRate.toFixed(1) + '%' : '—'}</td>
420
+ </tr>`;
421
+ }).join("\n");
422
+ return `
423
+ <section class="section">
424
+ <h2 class="section-title">Performance Latency Percentiles</h2>
425
+ <table class="data-table">
426
+ <thead>
427
+ <tr>
428
+ <th>Model</th>
429
+ <th>Req</th>
430
+ <th>Avg TTFT</th>
431
+ <th>P50 TTFT</th>
432
+ <th>P95 TTFT</th>
433
+ <th>P99 TTFT</th>
434
+ <th>Avg E2E</th>
435
+ <th>P50 E2E</th>
436
+ <th>P95 E2E</th>
437
+ <th>P99 E2E</th>
438
+ <th>Cache Hit</th>
439
+ </tr>
440
+ </thead>
441
+ <tbody>${rows}</tbody>
442
+ </table>
443
+ </section>`;
444
+ }
445
+ /** 渲染失败请求统计区域 */
446
+ function renderErrorStatsSection(data) {
447
+ const errors = data.errors;
448
+ if (!errors || errors.failedCount === 0)
449
+ return '';
450
+ const errorRatePct = (errors.errorRate * 100).toFixed(2) + '%';
451
+ const rateColor = errors.errorRate >= 0.05 ? 'var(--output)'
452
+ : errors.errorRate > 0 ? 'var(--tps)' : 'var(--cache)';
453
+ const rows = errors.byModel
454
+ .filter(m => m.failed > 0)
455
+ .map(m => {
456
+ const modelRate = m.total > 0 ? (m.failed / m.total * 100).toFixed(1) + '%' : '—';
457
+ return `<tr>
458
+ <td>${m.provider}</td>
459
+ <td>${m.model}</td>
460
+ <td>${m.total}</td>
461
+ <td style="color:var(--output)">${m.failed}</td>
462
+ <td style="color:var(--tps)">${m.total - m.failed}</td>
463
+ <td style="color:${errors.errorRate >= 0.05 ? 'var(--output)' : 'var(--tps)'}">${modelRate}</td>
464
+ </tr>`;
465
+ }).join('\n');
466
+ return `
467
+ <section class="section">
468
+ <h2 class="section-title">Failed Requests
469
+ <span style="margin-left:12px;font-size:0.85em;color:${rateColor}">
470
+ overall error rate: ${errorRatePct} (${errors.failedCount} failed / ${errors.successCount + errors.failedCount} total)
471
+ </span>
472
+ </h2>
473
+ <table class="data-table">
474
+ <thead>
475
+ <tr>
476
+ <th>Provider</th><th>Model</th><th>Total</th>
477
+ <th>Failed</th><th>Success</th><th>Error Rate</th>
478
+ </tr>
479
+ </thead>
480
+ <tbody>${rows}</tbody>
481
+ </table>
482
+ </section>`;
483
+ }
363
484
  function renderDailyTrendInit(data) {
364
485
  const days = data.daily.slice().reverse().map(d => d.day);
365
486
  const tokens = data.daily.slice().reverse().map(d => d.totalTokens);
366
487
  const costs = data.daily.slice().reverse().map(d => d.totalCost);
367
- return `
368
- var dailyDays = ${JSON.stringify(days)};
369
- var dailyTokens = ${JSON.stringify(tokens)};
370
- var dailyCosts = ${JSON.stringify(costs)};
371
-
372
- function initDailyChart() {
373
- var el = document.getElementById('daily-chart');
374
- if (!el) return;
375
- var chart = echarts.init(el);
376
- window.dailyChart = chart;
377
- var option = {
378
- tooltip: {
379
- trigger: 'axis',
380
- formatter: function(params) {
381
- var html = '<b>' + params[0].axisValue + '</b><br/>';
382
- params.forEach(function(p) {
383
- html += p.marker + ' ' + p.seriesName + ': ' + (p.seriesName === 'Cost' ? '$' + p.value.toFixed(4) : fmt(p.value)) + '<br/>';
384
- });
385
- return html;
386
- }
387
- },
388
- legend: {
389
- data: ['Tokens', 'Cost'],
390
- textStyle: { color: '#B0B0C0' },
391
- top: 5
392
- },
393
- grid: { left: 60, right: 60, bottom: 80, top: 40 },
394
- xAxis: {
395
- type: 'category',
396
- data: dailyDays,
397
- axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
398
- axisLine: { lineStyle: { color: '#2A2A35' } }
399
- },
400
- yAxis: [
401
- {
402
- type: 'value',
403
- name: 'Tokens',
404
- nameTextStyle: { color: '#B0B0C0' },
405
- axisLabel: { color: '#B0B0C0', formatter: fmt },
406
- splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
407
- },
408
- {
409
- type: 'value',
410
- name: 'Cost',
411
- nameTextStyle: { color: '#FFB800' },
412
- axisLabel: { color: '#FFB800', formatter: function(v) { return '$' + v.toFixed(4); } },
413
- splitLine: { show: false }
414
- }
415
- ],
416
- dataZoom: [{
417
- type: 'slider',
418
- bottom: 5,
419
- height: 20,
420
- borderColor: '#2A2A35',
421
- fillerColor: 'rgba(0,213,255,0.1)',
422
- handleStyle: { color: '#00D1FF' },
423
- textStyle: { color: '#B0B0C0' }
424
- }],
425
- series: [
426
- {
427
- name: 'Tokens',
428
- type: 'line',
429
- data: dailyTokens,
430
- smooth: true,
431
- symbol: 'none',
432
- lineStyle: { color: '#00D1FF', width: 2 },
433
- areaStyle: { color: 'rgba(0,209,255,0.15)' }
434
- },
435
- {
436
- name: 'Cost',
437
- type: 'line',
438
- yAxisIndex: 1,
439
- data: dailyCosts,
440
- smooth: true,
441
- symbol: 'none',
442
- lineStyle: { color: '#FFB800', width: 2 },
443
- areaStyle: { color: 'rgba(255,184,0,0.1)' }
444
- }
445
- ]
446
- };
447
- chart.setOption(option);
448
- chart.resize();
488
+ return `
489
+ var dailyDays = ${JSON.stringify(days)};
490
+ var dailyTokens = ${JSON.stringify(tokens)};
491
+ var dailyCosts = ${JSON.stringify(costs)};
492
+
493
+ function initDailyChart() {
494
+ var el = document.getElementById('daily-chart');
495
+ if (!el) return;
496
+ var chart = echarts.init(el);
497
+ window.dailyChart = chart;
498
+ var option = {
499
+ tooltip: {
500
+ trigger: 'axis',
501
+ formatter: function(params) {
502
+ var html = '<b>' + params[0].axisValue + '</b><br/>';
503
+ params.forEach(function(p) {
504
+ html += p.marker + ' ' + p.seriesName + ': ' + (p.seriesName === 'Cost' ? '$' + p.value.toFixed(4) : fmt(p.value)) + '<br/>';
505
+ });
506
+ return html;
507
+ }
508
+ },
509
+ legend: {
510
+ data: ['Tokens', 'Cost'],
511
+ textStyle: { color: '#B0B0C0' },
512
+ top: 5
513
+ },
514
+ grid: { left: 60, right: 60, bottom: 80, top: 40 },
515
+ xAxis: {
516
+ type: 'category',
517
+ data: dailyDays,
518
+ axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
519
+ axisLine: { lineStyle: { color: '#2A2A35' } }
520
+ },
521
+ yAxis: [
522
+ {
523
+ type: 'value',
524
+ name: 'Tokens',
525
+ nameTextStyle: { color: '#B0B0C0' },
526
+ axisLabel: { color: '#B0B0C0', formatter: fmt },
527
+ splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
528
+ },
529
+ {
530
+ type: 'value',
531
+ name: 'Cost',
532
+ nameTextStyle: { color: '#FFB800' },
533
+ axisLabel: { color: '#FFB800', formatter: function(v) { return '$' + v.toFixed(4); } },
534
+ splitLine: { show: false }
535
+ }
536
+ ],
537
+ dataZoom: [{
538
+ type: 'slider',
539
+ bottom: 5,
540
+ height: 20,
541
+ borderColor: '#2A2A35',
542
+ fillerColor: 'rgba(0,213,255,0.1)',
543
+ handleStyle: { color: '#00D1FF' },
544
+ textStyle: { color: '#B0B0C0' }
545
+ }],
546
+ series: [
547
+ {
548
+ name: 'Tokens',
549
+ type: 'line',
550
+ data: dailyTokens,
551
+ smooth: true,
552
+ symbol: 'none',
553
+ lineStyle: { color: '#00D1FF', width: 2 },
554
+ areaStyle: { color: 'rgba(0,209,255,0.15)' }
555
+ },
556
+ {
557
+ name: 'Cost',
558
+ type: 'line',
559
+ yAxisIndex: 1,
560
+ data: dailyCosts,
561
+ smooth: true,
562
+ symbol: 'none',
563
+ lineStyle: { color: '#FFB800', width: 2 },
564
+ areaStyle: { color: 'rgba(255,184,0,0.1)' }
565
+ }
566
+ ]
567
+ };
568
+ chart.setOption(option);
569
+ chart.resize();
449
570
  }`;
450
571
  }
451
572
  function renderHeatmapInit(data) {
@@ -453,54 +574,54 @@ function renderHeatmapInit(data) {
453
574
  const heatData = days.map(d => [d.day, Math.log10(d.totalTokens + 1)]);
454
575
  const minDate = days.length > 0 ? days[0].day : '';
455
576
  const maxDate = days.length > 0 ? days[days.length - 1].day : '';
456
- return `
457
- var heatData = ${JSON.stringify(heatData)};
458
-
459
- function initHeatmapChart() {
460
- var el = document.getElementById('heatmap-chart');
461
- if (!el) return;
462
- var chart = echarts.init(el);
463
- window.heatmapChart = chart;
464
- var option = {
465
- tooltip: {
466
- formatter: function(params) {
467
- var val = params.value;
468
- var rawTokens = Math.pow(10, val[1]) - 1;
469
- return '<b>' + val[0] + '</b><br/>Tokens: ' + fmt(Math.round(rawTokens));
470
- }
471
- },
472
- visualMap: {
473
- min: 0,
474
- max: Math.max.apply(null, heatData.map(function(d) { return d[1]; })) || 5,
475
- calculable: true,
476
- orient: 'horizontal',
477
- left: 'center',
478
- bottom: 10,
479
- textStyle: { color: '#B0B0C0' },
480
- inRange: {
481
- color: ['#0C0C0E', '#1a3a2a', '#00F593', '#00D1FF', '#B545FF']
482
- }
483
- },
484
- calendar: {
485
- left: 30,
486
- right: 30,
487
- top: 20,
488
- bottom: 60,
489
- range: ['${minDate}', '${maxDate}'],
490
- splitLine: { lineStyle: { color: '#2A2A35' } },
491
- dayLabel: { color: '#B0B0C0' },
492
- monthLabel: { color: '#B0B0C0' },
493
- yearLabel: { color: '#B0B0C0' },
494
- itemStyle: { color: '#16161A', borderColor: '#0C0C0E', borderWidth: 2 }
495
- },
496
- series: [{
497
- type: 'heatmap',
498
- coordinateSystem: 'calendar',
499
- data: heatData
500
- }]
501
- };
502
- chart.setOption(option);
503
- chart.resize();
577
+ return `
578
+ var heatData = ${JSON.stringify(heatData)};
579
+
580
+ function initHeatmapChart() {
581
+ var el = document.getElementById('heatmap-chart');
582
+ if (!el) return;
583
+ var chart = echarts.init(el);
584
+ window.heatmapChart = chart;
585
+ var option = {
586
+ tooltip: {
587
+ formatter: function(params) {
588
+ var val = params.value;
589
+ var rawTokens = Math.pow(10, val[1]) - 1;
590
+ return '<b>' + val[0] + '</b><br/>Tokens: ' + fmt(Math.round(rawTokens));
591
+ }
592
+ },
593
+ visualMap: {
594
+ min: 0,
595
+ max: Math.max.apply(null, heatData.map(function(d) { return d[1]; })) || 5,
596
+ calculable: true,
597
+ orient: 'horizontal',
598
+ left: 'center',
599
+ bottom: 10,
600
+ textStyle: { color: '#B0B0C0' },
601
+ inRange: {
602
+ color: ['#0C0C0E', '#1a3a2a', '#00F593', '#00D1FF', '#B545FF']
603
+ }
604
+ },
605
+ calendar: {
606
+ left: 30,
607
+ right: 30,
608
+ top: 20,
609
+ bottom: 60,
610
+ range: ['${minDate}', '${maxDate}'],
611
+ splitLine: { lineStyle: { color: '#2A2A35' } },
612
+ dayLabel: { color: '#B0B0C0' },
613
+ monthLabel: { color: '#B0B0C0' },
614
+ yearLabel: { color: '#B0B0C0' },
615
+ itemStyle: { color: '#16161A', borderColor: '#0C0C0E', borderWidth: 2 }
616
+ },
617
+ series: [{
618
+ type: 'heatmap',
619
+ coordinateSystem: 'calendar',
620
+ data: heatData
621
+ }]
622
+ };
623
+ chart.setOption(option);
624
+ chart.resize();
504
625
  }`;
505
626
  }
506
627
  export function generateUsageHtml(data) {
@@ -511,229 +632,235 @@ export function generateUsageHtml(data) {
511
632
  const scatterChartJs = renderScatterChartInit(data);
512
633
  const providerStr = renderProviderCards(data);
513
634
  const tableStr = renderDataTable(data);
635
+ const perfPercentileStr = renderPerfPercentileTable(data);
636
+ const errorStatsStr = renderErrorStatsSection(data);
514
637
  const dailyChartJs = renderDailyTrendInit(data);
515
638
  const heatmapJs = renderHeatmapInit(data);
516
639
  const hasPerf = data.perfSummary.length > 0;
517
640
  const jsonData = JSON.stringify(data);
518
- return `<!DOCTYPE html>
519
- <html lang="en">
520
- <head>
521
- <meta charset="UTF-8">
522
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
523
- <title>TokenWatch Usage Report</title>
524
- <link rel="preconnect" href="https://fonts.googleapis.com">
525
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
526
- <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
527
- <style>
528
- :root {
529
- --bg: #0C0C0E;
530
- --card: #16161A;
531
- --border: #2A2A35;
532
- --text: #E0E0F0;
533
- --text-dim: #B0B0C0;
534
- --cache: #00F593;
535
- --input: #00D1FF;
536
- --output: #B545FF;
537
- --tps: #FFB800;
538
- --radius: 8px;
539
- }
540
- * { margin: 0; padding: 0; box-sizing: border-box; }
541
- body {
542
- background: var(--bg);
543
- color: var(--text);
544
- font-family: 'Inter', -apple-system, sans-serif;
545
- font-size: 14px;
546
- line-height: 1.5;
547
- min-height: 100vh;
548
- }
549
- .container { max-width: 1400px; margin: 0 auto; padding: 24px 20px; }
550
- .header {
551
- display: flex; justify-content: space-between; align-items: center;
552
- padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 24px;
553
- }
554
- .header h1 { font-size: 22px; font-weight: 600; color: var(--text); }
555
- .header h1 span { color: var(--input); }
556
- .header .meta { font-size: 12px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; }
557
-
558
- .kpi-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 24px; }
559
- .kpi-card {
560
- background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
561
- padding: 16px; text-align: center;
562
- }
563
- .kpi-card.kpi-glow { box-shadow: 0 0 20px rgba(0,245,147,0.15); border-color: var(--cache); }
564
- .kpi-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
565
- .kpi-value { font-size: 26px; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--text); }
566
-
567
- .section { margin-bottom: 28px; }
568
- .section-title {
569
- font-size: 16px; font-weight: 600; margin-bottom: 12px;
570
- padding-bottom: 6px; border-bottom: 1px solid var(--border);
571
- }
572
- .chart-box {
573
- background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
574
- padding: 12px; height: 400px;
575
- }
576
-
577
- .tab-bar { display: flex; gap: 4px; margin-bottom: 12px; }
578
- .tab-btn {
579
- background: var(--card); border: 1px solid var(--border); color: var(--text-dim);
580
- padding: 6px 18px; border-radius: 4px 4px 0 0; cursor: pointer; font-size: 13px; font-family: 'Inter', sans-serif;
581
- }
582
- .tab-btn:hover { border-color: var(--input); color: var(--text); }
583
- .tab-btn.active {
584
- background: var(--border); color: var(--text); border-bottom-color: var(--border);
585
- }
586
- .tab-content { display: none; }
587
- .tab-content.active { display: block; }
588
-
589
- .provider-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
590
- .provider-card {
591
- background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
592
- padding: 14px;
593
- }
594
- .provider-name { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--input); }
595
- .provider-stat { display: flex; justify-content: space-between; font-size: 12px; padding: 2px 0; }
596
- .provider-stat .stat-label { color: var(--text-dim); }
597
-
598
- .data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
599
- .data-table th {
600
- background: var(--card); color: var(--text-dim); padding: 8px 10px;
601
- text-align: right; border-bottom: 1px solid var(--border); font-weight: 500;
602
- white-space: nowrap;
603
- }
604
- .data-table th:first-child { text-align: left; }
605
- .data-table td {
606
- padding: 6px 10px; text-align: right; border-bottom: 1px solid var(--border);
607
- font-family: 'JetBrains Mono', monospace;
608
- }
609
- .data-table td:first-child {
610
- text-align: left; color: var(--text); font-family: 'Inter', sans-serif;
611
- }
612
- .data-table tbody tr:hover { background: rgba(42,42,53,0.4); }
613
-
614
- .empty-state {
615
- background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
616
- padding: 40px; text-align: center; color: var(--text-dim);
617
- }
618
-
619
- .footer {
620
- margin-top: 40px; padding: 16px 0; border-top: 1px solid var(--border);
621
- text-align: center; font-size: 11px; color: var(--text-dim);
622
- }
623
-
624
- @media (max-width: 768px) {
625
- .kpi-row { grid-template-columns: repeat(2, 1fr); }
626
- .provider-row { grid-template-columns: 1fr; }
627
- .container { padding: 12px 10px; }
628
- .header { flex-direction: column; gap: 6px; align-items: flex-start; }
629
- .chart-box { height: 300px; }
630
- .data-table { font-size: 11px; }
631
- .data-table th, .data-table td { padding: 4px 6px; }
632
- }
633
- </style>
634
- </head>
635
- <body>
636
- <div class="container">
637
- <div class="header">
638
- <h1><span>TokenWatch</span> Usage Report</h1>
639
- <div class="meta">${metaStr}</div>
640
- </div>
641
-
642
- ${kpiStr}
643
-
644
- <div class="section">
645
- <div class="section-title">Model Comparison Matrix</div>
646
- ${modelChartVisible ? '<div class="chart-box" id="model-chart"></div>' : '<div class="empty-state">No models with >=1M tokens in this period.</div>'}
647
- </div>
648
-
649
- <div class="section">
650
- <div class="section-title">Provider Summary</div>
651
- <div class="provider-row">${providerStr}</div>
652
- </div>
653
-
654
- <div class="section">
655
- <div class="section-title">Detailed Data Grid</div>
656
- ${tableStr}
657
- </div>
658
-
659
- <div class="section">
660
- <div class="section-title">Efficiency vs Cost</div>
661
- ${hasPerf ? '<div class="chart-box" id="scatter-chart"></div>' : '<div class="empty-state">No performance data available for this period.</div>'}
662
- </div>
663
-
664
- <div class="section">
665
- <div class="section-title">Usage Timeline</div>
666
- <div class="tab-bar">
667
- <button class="tab-btn active" data-tab="daily" onclick="switchTab('daily')">Daily Trend</button>
668
- <button class="tab-btn" data-tab="heatmap" onclick="switchTab('heatmap')">Heatmap</button>
669
- </div>
670
- <div id="tab-daily" class="tab-content active">
671
- <div class="chart-box" id="daily-chart"></div>
672
- </div>
673
- <div id="tab-heatmap" class="tab-content">
674
- <div class="chart-box" id="heatmap-chart"></div>
675
- </div>
676
- </div>
677
-
678
- <div class="footer">
679
- Generated by TokenWatch &middot; Data: SQLite + JSONL &middot; Export: <a href="javascript:void(0)" onclick="downloadJSON()" style="color:var(--input);text-decoration:none">JSON</a> <span style="color:var(--text-dim)">(saves to browser download folder)</span>
680
- </div>
681
- </div>
682
-
683
- <script id="report-data" type="application/json">${jsonData}</script>
684
-
685
- <script>
686
- var fmt = function(v) {
687
- if (v == null) return '\u2014';
688
- if (v >= 1000000000) return (v/1000000000).toFixed(1)+'B';
689
- if (v >= 1000000) return (v/1000000).toFixed(1)+'M';
690
- if (v >= 1000) return (v/1000).toFixed(1)+'K';
691
- return String(v);
692
- };
693
-
694
- window.switchTab = function(name) {
695
- document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
696
- document.querySelectorAll('.tab-btn').forEach(function(el) { el.classList.remove('active'); });
697
- document.getElementById('tab-' + name).classList.add('active');
698
- document.querySelector('[data-tab="' + name + '"]').classList.add('active');
699
- setTimeout(function() {
700
- if (name === 'daily' && window.dailyChart) window.dailyChart.resize();
701
- if (name === 'heatmap' && window.heatmapChart) window.heatmapChart.resize();
702
- }, 50);
703
- };
704
-
705
- ${modelChartJs}
706
- ${scatterChartJs}
707
- ${dailyChartJs}
708
- ${heatmapJs}
709
-
710
- window.downloadJSON = function() {
711
- var d = document.getElementById('report-data');
712
- if (!d) return;
713
- var b = new Blob([d.textContent], { type: 'application/json' });
714
- var a = document.createElement('a');
715
- a.href = URL.createObjectURL(b);
716
- a.download = 'tokenwatch-data.json';
717
- document.body.appendChild(a);
718
- a.click();
719
- document.body.removeChild(a);
720
- setTimeout(function() { URL.revokeObjectURL(a.href); }, 100);
721
- };
722
-
723
- document.addEventListener('DOMContentLoaded', function() {
724
- ${modelChartVisible ? 'initModelChart();' : ''}
725
- ${hasPerf ? 'initScatterChart();' : ''}
726
- initDailyChart();
727
- initHeatmapChart();
728
- });
729
-
730
- window.addEventListener('resize', function() {
731
- ${modelChartVisible ? 'if (window.modelChart) window.modelChart.resize();' : ''}
732
- if (window.scatterChart) window.scatterChart.resize();
733
- if (window.dailyChart) window.dailyChart.resize();
734
- if (window.heatmapChart) window.heatmapChart.resize();
735
- });
736
- </script>
737
- </body>
641
+ return `<!DOCTYPE html>
642
+ <html lang="en">
643
+ <head>
644
+ <meta charset="UTF-8">
645
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
646
+ <title>TokenWatch Usage Report</title>
647
+ <link rel="preconnect" href="https://fonts.googleapis.com">
648
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
649
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
650
+ <style>
651
+ :root {
652
+ --bg: #0C0C0E;
653
+ --card: #16161A;
654
+ --border: #2A2A35;
655
+ --text: #E0E0F0;
656
+ --text-dim: #B0B0C0;
657
+ --cache: #00F593;
658
+ --input: #00D1FF;
659
+ --output: #B545FF;
660
+ --tps: #FFB800;
661
+ --radius: 8px;
662
+ }
663
+ * { margin: 0; padding: 0; box-sizing: border-box; }
664
+ body {
665
+ background: var(--bg);
666
+ color: var(--text);
667
+ font-family: 'Inter', -apple-system, sans-serif;
668
+ font-size: 14px;
669
+ line-height: 1.5;
670
+ min-height: 100vh;
671
+ }
672
+ .container { max-width: 1400px; margin: 0 auto; padding: 24px 20px; }
673
+ .header {
674
+ display: flex; justify-content: space-between; align-items: center;
675
+ padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 24px;
676
+ }
677
+ .header h1 { font-size: 22px; font-weight: 600; color: var(--text); }
678
+ .header h1 span { color: var(--input); }
679
+ .header .meta { font-size: 12px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; }
680
+
681
+ .kpi-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 24px; }
682
+ .kpi-card {
683
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
684
+ padding: 16px; text-align: center;
685
+ }
686
+ .kpi-card.kpi-glow { box-shadow: 0 0 20px rgba(0,245,147,0.15); border-color: var(--cache); }
687
+ .kpi-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
688
+ .kpi-value { font-size: 26px; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--text); }
689
+
690
+ .section { margin-bottom: 28px; }
691
+ .section-title {
692
+ font-size: 16px; font-weight: 600; margin-bottom: 12px;
693
+ padding-bottom: 6px; border-bottom: 1px solid var(--border);
694
+ }
695
+ .chart-box {
696
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
697
+ padding: 12px; height: 400px;
698
+ }
699
+
700
+ .tab-bar { display: flex; gap: 4px; margin-bottom: 12px; }
701
+ .tab-btn {
702
+ background: var(--card); border: 1px solid var(--border); color: var(--text-dim);
703
+ padding: 6px 18px; border-radius: 4px 4px 0 0; cursor: pointer; font-size: 13px; font-family: 'Inter', sans-serif;
704
+ }
705
+ .tab-btn:hover { border-color: var(--input); color: var(--text); }
706
+ .tab-btn.active {
707
+ background: var(--border); color: var(--text); border-bottom-color: var(--border);
708
+ }
709
+ .tab-content { display: none; }
710
+ .tab-content.active { display: block; }
711
+
712
+ .provider-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
713
+ .provider-card {
714
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
715
+ padding: 14px;
716
+ }
717
+ .provider-name { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--input); }
718
+ .provider-stat { display: flex; justify-content: space-between; font-size: 12px; padding: 2px 0; }
719
+ .provider-stat .stat-label { color: var(--text-dim); }
720
+
721
+ .data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
722
+ .data-table th {
723
+ background: var(--card); color: var(--text-dim); padding: 8px 10px;
724
+ text-align: right; border-bottom: 1px solid var(--border); font-weight: 500;
725
+ white-space: nowrap;
726
+ }
727
+ .data-table th:first-child { text-align: left; }
728
+ .data-table td {
729
+ padding: 6px 10px; text-align: right; border-bottom: 1px solid var(--border);
730
+ font-family: 'JetBrains Mono', monospace;
731
+ }
732
+ .data-table td:first-child {
733
+ text-align: left; color: var(--text); font-family: 'Inter', sans-serif;
734
+ }
735
+ .data-table tbody tr:hover { background: rgba(42,42,53,0.4); }
736
+
737
+ .empty-state {
738
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
739
+ padding: 40px; text-align: center; color: var(--text-dim);
740
+ }
741
+
742
+ .footer {
743
+ margin-top: 40px; padding: 16px 0; border-top: 1px solid var(--border);
744
+ text-align: center; font-size: 11px; color: var(--text-dim);
745
+ }
746
+
747
+ @media (max-width: 768px) {
748
+ .kpi-row { grid-template-columns: repeat(2, 1fr); }
749
+ .provider-row { grid-template-columns: 1fr; }
750
+ .container { padding: 12px 10px; }
751
+ .header { flex-direction: column; gap: 6px; align-items: flex-start; }
752
+ .chart-box { height: 300px; }
753
+ .data-table { font-size: 11px; }
754
+ .data-table th, .data-table td { padding: 4px 6px; }
755
+ }
756
+ </style>
757
+ </head>
758
+ <body>
759
+ <div class="container">
760
+ <div class="header">
761
+ <h1><span>TokenWatch</span> Usage Report</h1>
762
+ <div class="meta">${metaStr}</div>
763
+ </div>
764
+
765
+ ${kpiStr}
766
+
767
+ <div class="section">
768
+ <div class="section-title">Model Comparison Matrix</div>
769
+ ${modelChartVisible ? '<div class="chart-box" id="model-chart"></div>' : '<div class="empty-state">No models with >=1M tokens in this period.</div>'}
770
+ </div>
771
+
772
+ <div class="section">
773
+ <div class="section-title">Provider Summary</div>
774
+ <div class="provider-row">${providerStr}</div>
775
+ </div>
776
+
777
+ <div class="section">
778
+ <div class="section-title">Detailed Data Grid</div>
779
+ ${tableStr}
780
+ </div>
781
+
782
+ ${perfPercentileStr}
783
+
784
+ ${errorStatsStr}
785
+
786
+ <div class="section">
787
+ <div class="section-title">Efficiency vs Cost</div>
788
+ ${hasPerf ? '<div class="chart-box" id="scatter-chart"></div>' : '<div class="empty-state">No performance data available for this period.</div>'}
789
+ </div>
790
+
791
+ <div class="section">
792
+ <div class="section-title">Usage Timeline</div>
793
+ <div class="tab-bar">
794
+ <button class="tab-btn active" data-tab="daily" onclick="switchTab('daily')">Daily Trend</button>
795
+ <button class="tab-btn" data-tab="heatmap" onclick="switchTab('heatmap')">Heatmap</button>
796
+ </div>
797
+ <div id="tab-daily" class="tab-content active">
798
+ <div class="chart-box" id="daily-chart"></div>
799
+ </div>
800
+ <div id="tab-heatmap" class="tab-content">
801
+ <div class="chart-box" id="heatmap-chart"></div>
802
+ </div>
803
+ </div>
804
+
805
+ <div class="footer">
806
+ Generated by TokenWatch &middot; Data: SQLite + JSONL &middot; Export: <a href="javascript:void(0)" onclick="downloadJSON()" style="color:var(--input);text-decoration:none">JSON</a> <span style="color:var(--text-dim)">(saves to browser download folder)</span>
807
+ </div>
808
+ </div>
809
+
810
+ <script id="report-data" type="application/json">${jsonData}</script>
811
+
812
+ <script>
813
+ var fmt = function(v) {
814
+ if (v == null) return '\u2014';
815
+ if (v >= 1000000000) return (v/1000000000).toFixed(1)+'B';
816
+ if (v >= 1000000) return (v/1000000).toFixed(1)+'M';
817
+ if (v >= 1000) return (v/1000).toFixed(1)+'K';
818
+ return String(v);
819
+ };
820
+
821
+ window.switchTab = function(name) {
822
+ document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
823
+ document.querySelectorAll('.tab-btn').forEach(function(el) { el.classList.remove('active'); });
824
+ document.getElementById('tab-' + name).classList.add('active');
825
+ document.querySelector('[data-tab="' + name + '"]').classList.add('active');
826
+ setTimeout(function() {
827
+ if (name === 'daily' && window.dailyChart) window.dailyChart.resize();
828
+ if (name === 'heatmap' && window.heatmapChart) window.heatmapChart.resize();
829
+ }, 50);
830
+ };
831
+
832
+ ${modelChartJs}
833
+ ${scatterChartJs}
834
+ ${dailyChartJs}
835
+ ${heatmapJs}
836
+
837
+ window.downloadJSON = function() {
838
+ var d = document.getElementById('report-data');
839
+ if (!d) return;
840
+ var b = new Blob([d.textContent], { type: 'application/json' });
841
+ var a = document.createElement('a');
842
+ a.href = URL.createObjectURL(b);
843
+ a.download = 'tokenwatch-data.json';
844
+ document.body.appendChild(a);
845
+ a.click();
846
+ document.body.removeChild(a);
847
+ setTimeout(function() { URL.revokeObjectURL(a.href); }, 100);
848
+ };
849
+
850
+ document.addEventListener('DOMContentLoaded', function() {
851
+ ${modelChartVisible ? 'initModelChart();' : ''}
852
+ ${hasPerf ? 'initScatterChart();' : ''}
853
+ initDailyChart();
854
+ initHeatmapChart();
855
+ });
856
+
857
+ window.addEventListener('resize', function() {
858
+ ${modelChartVisible ? 'if (window.modelChart) window.modelChart.resize();' : ''}
859
+ if (window.scatterChart) window.scatterChart.resize();
860
+ if (window.dailyChart) window.dailyChart.resize();
861
+ if (window.heatmapChart) window.heatmapChart.resize();
862
+ });
863
+ </script>
864
+ </body>
738
865
  </html>`;
739
866
  }