token-speed-tester 1.6.0 → 1.8.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/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync } from "fs";
5
- import { dirname, join } from "path";
6
- import { fileURLToPath } from "url";
4
+ import { readFileSync as readFileSync2 } from "fs";
5
+ import { writeFile as fsWriteFile } from "fs/promises";
6
+ import { dirname as dirname2, join } from "path";
7
+ import { fileURLToPath as fileURLToPath2 } from "url";
7
8
  import { Command } from "commander";
8
9
  import chalk from "chalk";
10
+ import open from "open";
9
11
 
10
12
  // src/i18n.ts
11
13
  var SUPPORTED_LANGS = ["zh", "en"];
@@ -38,7 +40,10 @@ var zhMessages = {
38
40
  mean: "\u5747\u503C",
39
41
  min: "\u6700\u5C0F\u503C",
40
42
  max: "\u6700\u5927\u503C",
41
- stdDev: "\u6807\u51C6\u5DEE"
43
+ stdDev: "\u6807\u51C6\u5DEE",
44
+ p50: "P50",
45
+ p95: "P95",
46
+ p99: "P99"
42
47
  },
43
48
  statsLabels: {
44
49
  ttft: "TTFT (ms)",
@@ -55,7 +60,27 @@ var zhMessages = {
55
60
  averageSpeed: "\u5E73\u5747\u901F\u5EA6",
56
61
  peakSpeed: "\u5CF0\u503C\u901F\u5EA6",
57
62
  peakTps: "\u5CF0\u503C TPS"
58
- }
63
+ },
64
+ htmlTitle: "Token \u901F\u5EA6\u6D4B\u8BD5\u62A5\u544A",
65
+ htmlReportTitle: "LLM API Token \u6D41\u5F0F\u6027\u80FD\u6D4B\u8BD5\u62A5\u544A",
66
+ htmlGenerated: (file) => `\u2713 HTML \u62A5\u544A\u5DF2\u751F\u6210: ${file}`,
67
+ htmlOpenError: (file) => `\u65E0\u6CD5\u81EA\u52A8\u6253\u5F00\u62A5\u544A\uFF0C\u8BF7\u624B\u52A8\u6253\u5F00: ${file}`,
68
+ htmlConfigSection: "\u6D4B\u8BD5\u914D\u7F6E",
69
+ htmlSummarySection: "\u7EDF\u8BA1\u6C47\u603B",
70
+ htmlChartsSection: "\u56FE\u8868\u5206\u6790",
71
+ htmlDetailsSection: "\u8BE6\u7EC6\u6570\u636E",
72
+ htmlTestTime: "\u6D4B\u8BD5\u65F6\u95F4",
73
+ htmlMetric: "\u6307\u6807",
74
+ htmlValue: "\u6570\u503C",
75
+ htmlRun: "\u8FD0\u884C",
76
+ htmlTokens: "Token \u6570",
77
+ htmlDuration: "\u8017\u65F6",
78
+ htmlSpeed: "\u901F\u5EA6",
79
+ htmlTps: "TPS",
80
+ htmlAverageTps: "\u5E73\u5747 TPS",
81
+ htmlTpsDistribution: "TPS \u5206\u5E03",
82
+ htmlTimeUnit: "\u79D2",
83
+ htmlTpsChartHover: "\u6B21"
59
84
  };
60
85
  var enMessages = {
61
86
  defaultPrompt: "Write a short essay about AI",
@@ -85,7 +110,10 @@ var enMessages = {
85
110
  mean: "Mean",
86
111
  min: "Min",
87
112
  max: "Max",
88
- stdDev: "Std Dev"
113
+ stdDev: "Std Dev",
114
+ p50: "P50",
115
+ p95: "P95",
116
+ p99: "P99"
89
117
  },
90
118
  statsLabels: {
91
119
  ttft: "TTFT (ms)",
@@ -102,7 +130,27 @@ var enMessages = {
102
130
  averageSpeed: "Avg Speed",
103
131
  peakSpeed: "Peak Speed",
104
132
  peakTps: "Peak TPS"
105
- }
133
+ },
134
+ htmlTitle: "Token Speed Test Report",
135
+ htmlReportTitle: "LLM API Token Streaming Performance Report",
136
+ htmlGenerated: (file) => `\u2713 HTML report generated: ${file}`,
137
+ htmlOpenError: (file) => `Could not auto-open report, please open manually: ${file}`,
138
+ htmlConfigSection: "Test Configuration",
139
+ htmlSummarySection: "Summary Statistics",
140
+ htmlChartsSection: "Chart Analysis",
141
+ htmlDetailsSection: "Detailed Data",
142
+ htmlTestTime: "Test Time",
143
+ htmlMetric: "Metric",
144
+ htmlValue: "Value",
145
+ htmlRun: "Run",
146
+ htmlTokens: "Tokens",
147
+ htmlDuration: "Duration",
148
+ htmlSpeed: "Speed",
149
+ htmlTps: "TPS",
150
+ htmlAverageTps: "Average TPS",
151
+ htmlTpsDistribution: "TPS Distribution",
152
+ htmlTimeUnit: "s",
153
+ htmlTpsChartHover: "count"
106
154
  };
107
155
  function isSupportedLang(value) {
108
156
  return SUPPORTED_LANGS.includes(value);
@@ -405,6 +453,29 @@ function standardDeviation(values) {
405
453
  const squareDiffs = values.map((v) => Math.pow(v - avg, 2));
406
454
  return Math.sqrt(mean(squareDiffs));
407
455
  }
456
+ function calculatePercentile(values, p) {
457
+ if (values.length === 0) return 0;
458
+ if (values.length === 1) return values[0];
459
+ const index = p / 100 * (values.length - 1);
460
+ const lowerIndex = Math.floor(index);
461
+ const upperIndex = Math.ceil(index);
462
+ const fraction = index - lowerIndex;
463
+ if (lowerIndex === upperIndex) {
464
+ return values[lowerIndex];
465
+ }
466
+ return values[lowerIndex] + fraction * (values[upperIndex] - values[lowerIndex]);
467
+ }
468
+ function calculatePercentiles(values) {
469
+ if (values.length === 0) {
470
+ return { p50: 0, p95: 0, p99: 0 };
471
+ }
472
+ const sorted = [...values].sort((a, b) => a - b);
473
+ return {
474
+ p50: calculatePercentile(sorted, 50),
475
+ p95: calculatePercentile(sorted, 95),
476
+ p99: calculatePercentile(sorted, 99)
477
+ };
478
+ }
408
479
  function calculateStats(allMetrics) {
409
480
  if (allMetrics.length === 0) {
410
481
  throw new Error("Cannot calculate stats from empty metrics array");
@@ -459,6 +530,14 @@ function calculateStats(allMetrics) {
459
530
  peakTps: standardDeviation(peakTpsValues),
460
531
  tps: []
461
532
  },
533
+ percentiles: {
534
+ ttft: calculatePercentiles(ttfts),
535
+ totalTime: calculatePercentiles(totalTimes),
536
+ totalTokens: calculatePercentiles(totalTokens),
537
+ averageSpeed: calculatePercentiles(averageSpeeds),
538
+ peakSpeed: calculatePercentiles(peakSpeeds),
539
+ peakTps: calculatePercentiles(peakTpsValues)
540
+ },
462
541
  sampleSize
463
542
  };
464
543
  }
@@ -469,7 +548,7 @@ var BLOCK_CHAR = "\u2588";
469
548
  var CHART_WIDTH = 50;
470
549
  var CHART_HEIGHT = 10;
471
550
  var STAT_LABEL_WIDTH = 15;
472
- var STAT_VALUE_WIDTH = 10;
551
+ var STAT_VALUE_WIDTH = 8;
473
552
  var Y_LABEL_WIDTH = 4;
474
553
  function padEndWidth(text, width) {
475
554
  const currentWidth = stringWidth(text);
@@ -580,7 +659,7 @@ function renderStatsTable(stats, lang = DEFAULT_LANG) {
580
659
  const lines = [];
581
660
  lines.push("");
582
661
  lines.push(messages.statsSummaryTitle(stats.sampleSize));
583
- const headerRow = "\u2502 " + padEndWidth(messages.statsHeaders.metric, STAT_LABEL_WIDTH) + " \u2502 " + padStartWidth(messages.statsHeaders.mean, STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(messages.statsHeaders.min, STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(messages.statsHeaders.max, STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(messages.statsHeaders.stdDev, STAT_VALUE_WIDTH) + " \u2502";
662
+ const headerRow = "\u2502 " + padEndWidth(messages.statsHeaders.metric, STAT_LABEL_WIDTH) + " \u2502 " + padStartWidth(messages.statsHeaders.mean, STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(messages.statsHeaders.p50, STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(messages.statsHeaders.p95, STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(messages.statsHeaders.p99, STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(messages.statsHeaders.min, STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(messages.statsHeaders.max, STAT_VALUE_WIDTH) + " \u2502";
584
663
  const tableWidth = stringWidth(headerRow) - 2;
585
664
  lines.push("\u250C" + "\u2500".repeat(tableWidth) + "\u2510");
586
665
  lines.push(headerRow);
@@ -589,9 +668,11 @@ function renderStatsTable(stats, lang = DEFAULT_LANG) {
589
668
  formatStatRow(
590
669
  messages.statsLabels.ttft,
591
670
  stats.mean.ttft,
671
+ stats.percentiles.ttft.p50,
672
+ stats.percentiles.ttft.p95,
673
+ stats.percentiles.ttft.p99,
592
674
  stats.min.ttft,
593
675
  stats.max.ttft,
594
- stats.stdDev.ttft,
595
676
  "f"
596
677
  )
597
678
  );
@@ -600,9 +681,11 @@ function renderStatsTable(stats, lang = DEFAULT_LANG) {
600
681
  formatStatRow(
601
682
  messages.statsLabels.totalTime,
602
683
  stats.mean.totalTime,
684
+ stats.percentiles.totalTime.p50,
685
+ stats.percentiles.totalTime.p95,
686
+ stats.percentiles.totalTime.p99,
603
687
  stats.min.totalTime,
604
688
  stats.max.totalTime,
605
- stats.stdDev.totalTime,
606
689
  "f"
607
690
  )
608
691
  );
@@ -611,9 +694,11 @@ function renderStatsTable(stats, lang = DEFAULT_LANG) {
611
694
  formatStatRow(
612
695
  messages.statsLabels.totalTokens,
613
696
  stats.mean.totalTokens,
697
+ stats.percentiles.totalTokens.p50,
698
+ stats.percentiles.totalTokens.p95,
699
+ stats.percentiles.totalTokens.p99,
614
700
  stats.min.totalTokens,
615
701
  stats.max.totalTokens,
616
- stats.stdDev.totalTokens,
617
702
  "f"
618
703
  )
619
704
  );
@@ -622,9 +707,11 @@ function renderStatsTable(stats, lang = DEFAULT_LANG) {
622
707
  formatStatRow(
623
708
  messages.statsLabels.averageSpeed,
624
709
  stats.mean.averageSpeed,
710
+ stats.percentiles.averageSpeed.p50,
711
+ stats.percentiles.averageSpeed.p95,
712
+ stats.percentiles.averageSpeed.p99,
625
713
  stats.min.averageSpeed,
626
714
  stats.max.averageSpeed,
627
- stats.stdDev.averageSpeed,
628
715
  "f"
629
716
  )
630
717
  );
@@ -633,9 +720,11 @@ function renderStatsTable(stats, lang = DEFAULT_LANG) {
633
720
  formatStatRow(
634
721
  messages.statsLabels.peakSpeed,
635
722
  stats.mean.peakSpeed,
723
+ stats.percentiles.peakSpeed.p50,
724
+ stats.percentiles.peakSpeed.p95,
725
+ stats.percentiles.peakSpeed.p99,
636
726
  stats.min.peakSpeed,
637
727
  stats.max.peakSpeed,
638
- stats.stdDev.peakSpeed,
639
728
  "f"
640
729
  )
641
730
  );
@@ -644,18 +733,20 @@ function renderStatsTable(stats, lang = DEFAULT_LANG) {
644
733
  formatStatRow(
645
734
  messages.statsLabels.peakTps,
646
735
  stats.mean.peakTps,
736
+ stats.percentiles.peakTps.p50,
737
+ stats.percentiles.peakTps.p95,
738
+ stats.percentiles.peakTps.p99,
647
739
  stats.min.peakTps,
648
740
  stats.max.peakTps,
649
- stats.stdDev.peakTps,
650
741
  "f"
651
742
  )
652
743
  );
653
744
  lines.push("\u2514" + "\u2500".repeat(tableWidth) + "\u2518");
654
745
  return lines.join("\n");
655
746
  }
656
- function formatStatRow(label, mean2, min, max, stdDev, format) {
747
+ function formatStatRow(label, mean2, p50, p95, p99, min, max, format) {
657
748
  const fmt = (n) => format === "f" ? n.toFixed(2) : n.toFixed(0);
658
- return "\u2502 " + padEndWidth(label, STAT_LABEL_WIDTH) + " \u2502 " + padStartWidth(fmt(mean2), STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(fmt(min), STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(fmt(max), STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(fmt(stdDev), STAT_VALUE_WIDTH) + " \u2502";
749
+ return "\u2502 " + padEndWidth(label, STAT_LABEL_WIDTH) + " \u2502 " + padStartWidth(fmt(mean2), STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(fmt(p50), STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(fmt(p95), STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(fmt(p99), STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(fmt(min), STAT_VALUE_WIDTH) + " \u2502 " + padStartWidth(fmt(max), STAT_VALUE_WIDTH) + " \u2502";
659
750
  }
660
751
  function formatTimeWithDecimals(ms) {
661
752
  if (ms === Math.floor(ms)) {
@@ -694,12 +785,516 @@ function renderReport(stats, lang = DEFAULT_LANG) {
694
785
  return lines.join("\n");
695
786
  }
696
787
 
788
+ // src/html-report.ts
789
+ import { readFileSync } from "fs";
790
+ import { dirname, resolve } from "path";
791
+ import { fileURLToPath } from "url";
792
+ var CHART_COLORS = [
793
+ "#00f5ff",
794
+ // cyan
795
+ "#ff00aa",
796
+ // magenta
797
+ "#ffcc00",
798
+ // yellow
799
+ "#00ff88",
800
+ // green
801
+ "#ff6600",
802
+ // orange
803
+ "#aa00ff"
804
+ // purple
805
+ ];
806
+ var PALETTE = {
807
+ bg: "#0a0a0f",
808
+ bgSecondary: "#12121a",
809
+ bgCard: "#1a1a24",
810
+ border: "#2a2a3a",
811
+ text: "#e4e4eb",
812
+ textMuted: "#6a6a7a",
813
+ accent: "#00f5ff",
814
+ accentSecondary: "#ff00aa",
815
+ accentTertiary: "#ffcc00"
816
+ };
817
+ function formatNumber(num, decimals = 2) {
818
+ return num.toFixed(decimals);
819
+ }
820
+ function formatTime(ms) {
821
+ if (ms < 1e3) {
822
+ return `${ms.toFixed(0)}ms`;
823
+ }
824
+ return `${(ms / 1e3).toFixed(2)}s`;
825
+ }
826
+ function escapeHtml(text) {
827
+ const map = {
828
+ "&": "&amp;",
829
+ "<": "&lt;",
830
+ ">": "&gt;",
831
+ '"': "&quot;",
832
+ "'": "&#039;"
833
+ };
834
+ return text.replace(/[&<>"']/g, (m) => map[m]);
835
+ }
836
+ function loadTemplate() {
837
+ const __filename = fileURLToPath(import.meta.url);
838
+ const __dirname = dirname(__filename);
839
+ return readFileSync(resolve(__dirname, "template.html"), "utf-8");
840
+ }
841
+ function replaceTemplate(template, data) {
842
+ let result = template;
843
+ for (const [key, value] of Object.entries(data)) {
844
+ result = result.replaceAll(new RegExp(`{{${key}}}`, "g"), value);
845
+ }
846
+ return result;
847
+ }
848
+ function generateSpeedChart(results, messages) {
849
+ const allTps = results.flatMap((r) => r.tps);
850
+ if (allTps.length === 0) {
851
+ return `<div class="no-data">${messages.noChartData || "No data available"}</div>`;
852
+ }
853
+ const maxTps = Math.max(...allTps, 1);
854
+ const maxDuration = Math.max(...results.map((r) => r.tps.length));
855
+ const width = 800;
856
+ const height = 320;
857
+ const padding = { top: 30, right: 30, bottom: 45, left: 55 };
858
+ const chartWidth = width - padding.left - padding.right;
859
+ const chartHeight = height - padding.top - padding.bottom;
860
+ const avgTps = [];
861
+ for (let i = 0; i < maxDuration; i++) {
862
+ const values = results.map((r) => r.tps[i] ?? 0);
863
+ avgTps.push(values.reduce((a, b) => a + b, 0) / values.length);
864
+ }
865
+ const polylines = results.map((result, idx) => {
866
+ const color = CHART_COLORS[idx % CHART_COLORS.length];
867
+ let points = "";
868
+ let areaPoints = `${padding.left},${height - padding.bottom} `;
869
+ result.tps.forEach((tps, i) => {
870
+ const x = padding.left + i / Math.max(maxDuration - 1, 1) * chartWidth;
871
+ const y = padding.top + chartHeight - tps / maxTps * chartHeight;
872
+ points += `${x},${y} `;
873
+ areaPoints += `${x},${y} `;
874
+ });
875
+ areaPoints += `${padding.left + chartWidth},${height - padding.bottom}`;
876
+ return `
877
+ <defs>
878
+ <linearGradient id="grad-${idx}" x1="0%" y1="0%" x2="0%" y2="100%">
879
+ <stop offset="0%" style="stop-color:${color};stop-opacity:0.3"/>
880
+ <stop offset="100%" style="stop-color:${color};stop-opacity:0"/>
881
+ </linearGradient>
882
+ </defs>
883
+ <polygon
884
+ points="${areaPoints.trim()}"
885
+ fill="url(#grad-${idx})"
886
+ class="area-${idx}"
887
+ />
888
+ <polyline
889
+ fill="none"
890
+ stroke="${color}"
891
+ stroke-width="2.5"
892
+ points="${points.trim()}"
893
+ class="line line-${idx}"
894
+ data-run="${idx + 1}"
895
+ >
896
+ <animate
897
+ attributeName="stroke-dasharray"
898
+ from="0,2000"
899
+ to="2000,0"
900
+ dur="1.5s"
901
+ fill="freeze"
902
+ calcMode="spline"
903
+ keySplines="0.4 0 0.2 1"
904
+ />
905
+ </polyline>
906
+ ${result.tps.map((tps, i) => {
907
+ const x = padding.left + i / Math.max(maxDuration - 1, 1) * chartWidth;
908
+ const y = padding.top + chartHeight - tps / maxTps * chartHeight;
909
+ return `<circle cx="${x}" cy="${y}" r="4" fill="${PALETTE.bg}" stroke="${color}" stroke-width="2" class="dot-${idx}" opacity="0"><title>${messages.htmlAverageTps || "Avg TPS"} ${i}s: ${tps.toFixed(1)}</title>
910
+ <animate attributeName="opacity" from="0" to="1" begin="${0.5 + i * 0.05}s" dur="0.3s" fill="freeze"/>
911
+ </circle>`;
912
+ }).join("")}
913
+ `;
914
+ }).join("\n ");
915
+ let avgPoints = "";
916
+ avgTps.forEach((tps, i) => {
917
+ const x = padding.left + i / Math.max(maxDuration - 1, 1) * chartWidth;
918
+ const y = padding.top + chartHeight - tps / maxTps * chartHeight;
919
+ avgPoints += `${x},${y} `;
920
+ });
921
+ const yLabels = [];
922
+ for (let i = 0; i <= 5; i++) {
923
+ const value = Math.round(maxTps * i / 5);
924
+ const y = padding.top + chartHeight - i / 5 * chartHeight;
925
+ yLabels.push(
926
+ `<text x="${padding.left - 12}" y="${y + 4}" text-anchor="end" font-size="11" fill="${PALETTE.textMuted}">${value}</text>`
927
+ );
928
+ if (i > 0) {
929
+ const yLine = padding.top + chartHeight - i / 5 * chartHeight;
930
+ yLabels.push(
931
+ `<line x1="${padding.left}" y1="${yLine}" x2="${width - padding.right}" y2="${yLine}" stroke="${PALETTE.border}" stroke-width="1" opacity="0.5"/>`
932
+ );
933
+ }
934
+ }
935
+ const xLabels = [];
936
+ const xSteps = Math.min(maxDuration, 10);
937
+ for (let i = 0; i < xSteps; i++) {
938
+ const x = padding.left + i / Math.max(xSteps - 1, 1) * chartWidth;
939
+ const label = i.toString();
940
+ xLabels.push(
941
+ `<text x="${x}" y="${height - padding.bottom + 20}" text-anchor="middle" font-size="11" fill="${PALETTE.textMuted}">${label}${messages.htmlTimeUnit}</text>`
942
+ );
943
+ }
944
+ return `
945
+ <svg viewBox="0 0 ${width} ${height}" class="chart" id="speedChart">
946
+ <style>
947
+ #speedChart .line { stroke-dasharray: 2000; stroke-dashoffset: 0; }
948
+ #speedChart .line:hover { stroke-width: 4; filter: drop-shadow(0 0 8px currentColor); }
949
+ #speedChart circle { transition: all 0.2s ease; cursor: pointer; }
950
+ #speedChart circle:hover { r: 6; stroke-width: 3; }
951
+ </style>
952
+ <rect x="${padding.left}" y="${padding.top}" width="${chartWidth}" height="${chartHeight}" fill="${PALETTE.bgCard}" rx="4"/>
953
+ ${yLabels.join("\n ")}
954
+ ${xLabels.join("\n ")}
955
+ <line x1="${padding.left}" y1="${padding.top}" x2="${padding.left}" y2="${height - padding.bottom}" stroke="${PALETTE.border}" stroke-width="2"/>
956
+ <line x1="${padding.left}" y1="${height - padding.bottom}" x2="${width - padding.right}" y2="${height - padding.bottom}" stroke="${PALETTE.border}" stroke-width="2"/>
957
+ ${polylines}
958
+ <polyline
959
+ fill="none"
960
+ stroke="${PALETTE.text}"
961
+ stroke-width="2"
962
+ stroke-dasharray="6,4"
963
+ opacity="0.7"
964
+ points="${avgPoints.trim()}"
965
+ />
966
+ <text x="${padding.left + chartWidth / 2}" y="${height - 8}" text-anchor="middle" font-size="11" fill="${PALETTE.textMuted}">TIME (${messages.htmlTimeUnit})</text>
967
+ <text x="12" y="${padding.top + chartHeight / 2}" text-anchor="middle" font-size="11" fill="${PALETTE.textMuted}" transform="rotate(-90, 12, ${padding.top + chartHeight / 2})">TPS</text>
968
+ </svg>
969
+ <div class="chart-legend">
970
+ <div class="legend-item">
971
+ <span class="legend-line avg"></span>
972
+ <span>${messages.htmlSummarySection}</span>
973
+ </div>
974
+ ${results.map((_, idx) => {
975
+ const color = CHART_COLORS[idx % CHART_COLORS.length];
976
+ return `
977
+ <div class="legend-item">
978
+ <span class="legend-line" style="background: ${color};"></span>
979
+ <span>${messages.htmlRun} ${idx + 1}</span>
980
+ </div>`;
981
+ }).join("")}
982
+ </div>
983
+ `;
984
+ }
985
+ function generateTPSHistogram(stats, messages) {
986
+ const allTps = stats.mean.tps;
987
+ if (allTps.length === 0) {
988
+ return `<div class="no-data">${messages.noTpsData || "No TPS data available"}</div>`;
989
+ }
990
+ const maxTps = Math.max(...allTps);
991
+ const width = 400;
992
+ const height = 280;
993
+ const padding = { top: 25, right: 20, bottom: 40, left: 50 };
994
+ const chartWidth = width - padding.left - padding.right;
995
+ const chartHeight = height - padding.top - padding.bottom;
996
+ const bars = allTps.map((tps, i) => {
997
+ const barWidth = chartWidth / allTps.length - 2;
998
+ const x = padding.left + i / allTps.length * chartWidth;
999
+ const barHeight = tps / maxTps * chartHeight;
1000
+ const y = padding.top + chartHeight - barHeight;
1001
+ const hue = 180 + tps / maxTps * 60;
1002
+ const color = `hsl(${hue}, 100%, 60%)`;
1003
+ return `
1004
+ <rect
1005
+ x="${x}"
1006
+ y="${y}"
1007
+ width="${barWidth}"
1008
+ height="${barHeight}"
1009
+ fill="${color}"
1010
+ class="bar"
1011
+ data-second="${i}"
1012
+ data-tps="${tps.toFixed(2)}"
1013
+ rx="2"
1014
+ >
1015
+ <title>${messages.htmlAverageTps || "Average TPS"} ${i}s: ${tps.toFixed(1)}</title>
1016
+ <animate
1017
+ attributeName="height"
1018
+ from="0"
1019
+ to="${barHeight}"
1020
+ dur="0.8s"
1021
+ fill="freeze"
1022
+ calcMode="spline"
1023
+ keySplines="0.4 0 0.2 1"
1024
+ begin="${i * 0.05}s"
1025
+ />
1026
+ <animate
1027
+ attributeName="y"
1028
+ from="${height - padding.bottom}"
1029
+ to="${y}"
1030
+ dur="0.8s"
1031
+ fill="freeze"
1032
+ calcMode="spline"
1033
+ keySplines="0.4 0 0.2 1"
1034
+ begin="${i * 0.05}s"
1035
+ />
1036
+ </rect>
1037
+ `;
1038
+ }).join("");
1039
+ const yLabels = [];
1040
+ for (let i = 0; i <= 5; i++) {
1041
+ const value = Math.round(maxTps * i / 5);
1042
+ const y = padding.top + chartHeight - i / 5 * chartHeight;
1043
+ yLabels.push(
1044
+ `<text x="${padding.left - 10}" y="${y + 4}" text-anchor="end" font-size="11" fill="${PALETTE.textMuted}">${value}</text>`
1045
+ );
1046
+ }
1047
+ const xLabels = [];
1048
+ const xSteps = Math.min(allTps.length, 8);
1049
+ for (let i = 0; i < xSteps; i++) {
1050
+ const x = padding.left + i / Math.max(xSteps - 1, 1) * chartWidth;
1051
+ const label = i.toString();
1052
+ xLabels.push(
1053
+ `<text x="${x}" y="${height - padding.bottom + 18}" text-anchor="middle" font-size="11" fill="${PALETTE.textMuted}">${label}${messages.htmlTimeUnit}</text>`
1054
+ );
1055
+ }
1056
+ return `
1057
+ <svg viewBox="0 0 ${width} ${height}" class="chart" id="tpsChart">
1058
+ <style>
1059
+ #tpsChart .bar { transition: all 0.2s ease; cursor: pointer; opacity: 0.9; }
1060
+ #tpsChart .bar:hover { opacity: 1; filter: brightness(1.2); }
1061
+ </style>
1062
+ <rect x="${padding.left}" y="${padding.top}" width="${chartWidth}" height="${chartHeight}" fill="${PALETTE.bgCard}" rx="4"/>
1063
+ ${yLabels.join("\n ")}
1064
+ ${xLabels.join("\n ")}
1065
+ <line x1="${padding.left}" y1="${padding.top}" x2="${padding.left}" y2="${height - padding.bottom}" stroke="${PALETTE.border}" stroke-width="2"/>
1066
+ <line x1="${padding.left}" y1="${height - padding.bottom}" x2="${width - padding.right}" y2="${height - padding.bottom}" stroke="${PALETTE.border}" stroke-width="2"/>
1067
+ ${bars}
1068
+ </svg>
1069
+ `;
1070
+ }
1071
+ function generateHTMLReport(options2) {
1072
+ const { config, singleResults, stats, lang, messages } = options2;
1073
+ const isZh = lang === "zh";
1074
+ const testTime = (/* @__PURE__ */ new Date()).toLocaleString(isZh ? "zh-CN" : "en-US");
1075
+ const summaryCards = [
1076
+ {
1077
+ label: messages.statsLabels.ttft,
1078
+ value: formatTime(stats.mean.ttft),
1079
+ detail: `${messages.statsHeaders.min}: ${formatTime(stats.min.ttft)} \xB7 ${messages.statsHeaders.max}: ${formatTime(stats.max.ttft)}`,
1080
+ accent: PALETTE.accent
1081
+ },
1082
+ {
1083
+ label: messages.statsLabels.averageSpeed,
1084
+ value: formatNumber(stats.mean.averageSpeed),
1085
+ detail: `${messages.statsHeaders.min}: ${formatNumber(stats.min.averageSpeed)} \xB7 ${messages.statsHeaders.max}: ${formatNumber(stats.max.averageSpeed)}`,
1086
+ accent: PALETTE.accentSecondary,
1087
+ unit: messages.htmlSpeed
1088
+ },
1089
+ {
1090
+ label: messages.statsLabels.peakSpeed,
1091
+ value: formatNumber(stats.mean.peakSpeed),
1092
+ detail: `${messages.statsHeaders.min}: ${formatNumber(stats.min.peakSpeed)} \xB7 ${messages.statsHeaders.max}: ${formatNumber(stats.max.peakSpeed)}`,
1093
+ accent: PALETTE.accentTertiary,
1094
+ unit: messages.htmlSpeed
1095
+ },
1096
+ {
1097
+ label: messages.statsLabels.totalTokens,
1098
+ value: formatNumber(stats.mean.totalTokens, 0),
1099
+ detail: `${messages.statsHeaders.min}: ${formatNumber(stats.min.totalTokens, 0)} \xB7 ${messages.statsHeaders.max}: ${formatNumber(stats.max.totalTokens, 0)}`,
1100
+ accent: "#00ff88"
1101
+ }
1102
+ ];
1103
+ const detailRows = singleResults.map(
1104
+ (result, idx) => `
1105
+ <tr>
1106
+ <td><span class="run-badge">${idx + 1}</span></td>
1107
+ <td>${formatTime(result.ttft)}</td>
1108
+ <td>${formatTime(result.totalTime)}</td>
1109
+ <td>${result.totalTokens}</td>
1110
+ <td>${formatNumber(result.averageSpeed)}</td>
1111
+ <td>${formatNumber(result.peakSpeed)}</td>
1112
+ <td>${result.peakTps}</td>
1113
+ </tr>
1114
+ `
1115
+ ).join("");
1116
+ const statsRows = [
1117
+ {
1118
+ metric: messages.statsLabels.ttft,
1119
+ mean: formatTime(stats.mean.ttft),
1120
+ p50: formatTime(stats.percentiles.ttft.p50),
1121
+ p95: formatTime(stats.percentiles.ttft.p95),
1122
+ p99: formatTime(stats.percentiles.ttft.p99),
1123
+ min: formatTime(stats.min.ttft),
1124
+ max: formatTime(stats.max.ttft)
1125
+ },
1126
+ {
1127
+ metric: messages.statsLabels.totalTime,
1128
+ mean: formatTime(stats.mean.totalTime),
1129
+ p50: formatTime(stats.percentiles.totalTime.p50),
1130
+ p95: formatTime(stats.percentiles.totalTime.p95),
1131
+ p99: formatTime(stats.percentiles.totalTime.p99),
1132
+ min: formatTime(stats.min.totalTime),
1133
+ max: formatTime(stats.max.totalTime)
1134
+ },
1135
+ {
1136
+ metric: messages.statsLabels.totalTokens,
1137
+ mean: formatNumber(stats.mean.totalTokens, 1),
1138
+ p50: formatNumber(stats.percentiles.totalTokens.p50, 0),
1139
+ p95: formatNumber(stats.percentiles.totalTokens.p95, 0),
1140
+ p99: formatNumber(stats.percentiles.totalTokens.p99, 0),
1141
+ min: formatNumber(stats.min.totalTokens, 0),
1142
+ max: formatNumber(stats.max.totalTokens, 0)
1143
+ },
1144
+ {
1145
+ metric: messages.statsLabels.averageSpeed,
1146
+ mean: formatNumber(stats.mean.averageSpeed),
1147
+ p50: formatNumber(stats.percentiles.averageSpeed.p50),
1148
+ p95: formatNumber(stats.percentiles.averageSpeed.p95),
1149
+ p99: formatNumber(stats.percentiles.averageSpeed.p99),
1150
+ min: formatNumber(stats.min.averageSpeed),
1151
+ max: formatNumber(stats.max.averageSpeed)
1152
+ },
1153
+ {
1154
+ metric: messages.statsLabels.peakSpeed,
1155
+ mean: formatNumber(stats.mean.peakSpeed),
1156
+ p50: formatNumber(stats.percentiles.peakSpeed.p50),
1157
+ p95: formatNumber(stats.percentiles.peakSpeed.p95),
1158
+ p99: formatNumber(stats.percentiles.peakSpeed.p99),
1159
+ min: formatNumber(stats.min.peakSpeed),
1160
+ max: formatNumber(stats.max.peakSpeed)
1161
+ },
1162
+ {
1163
+ metric: messages.statsLabels.peakTps,
1164
+ mean: formatNumber(stats.mean.peakTps),
1165
+ p50: formatNumber(stats.percentiles.peakTps.p50),
1166
+ p95: formatNumber(stats.percentiles.peakTps.p95),
1167
+ p99: formatNumber(stats.percentiles.peakTps.p99),
1168
+ min: formatNumber(stats.min.peakTps),
1169
+ max: formatNumber(stats.max.peakTps)
1170
+ }
1171
+ ].map(
1172
+ (row) => `
1173
+ <tr>
1174
+ <td class="metric-name">${row.metric}</td>
1175
+ <td class="value-primary">${row.mean}</td>
1176
+ <td>${row.p50}</td>
1177
+ <td>${row.p95}</td>
1178
+ <td>${row.p99}</td>
1179
+ <td>${row.min}</td>
1180
+ <td>${row.max}</td>
1181
+ </tr>
1182
+ `
1183
+ ).join("");
1184
+ const speedChart = generateSpeedChart(singleResults, messages);
1185
+ const tpsChart = generateTPSHistogram(stats, messages);
1186
+ const configGridHtml = `
1187
+ <div class="config-grid">
1188
+ <div class="config-item">
1189
+ <span class="config-label">${messages.configLabels.provider}</span>
1190
+ <span class="config-value">${config.provider.toUpperCase()}</span>
1191
+ </div>
1192
+ <div class="config-item">
1193
+ <span class="config-label">${messages.configLabels.model}</span>
1194
+ <span class="config-value">${escapeHtml(config.model)}</span>
1195
+ </div>
1196
+ <div class="config-item">
1197
+ <span class="config-label">${messages.configLabels.maxTokens}</span>
1198
+ <span class="config-value">${config.maxTokens}</span>
1199
+ </div>
1200
+ <div class="config-item">
1201
+ <span class="config-label">${messages.configLabels.runs}</span>
1202
+ <span class="config-value">${config.runCount}</span>
1203
+ </div>
1204
+ <div class="config-item wide">
1205
+ <span class="config-label">${messages.configLabels.prompt}</span>
1206
+ <span class="config-value">"${escapeHtml(config.prompt)}"</span>
1207
+ </div>
1208
+ </div>
1209
+ `;
1210
+ const summaryCardsHtml = summaryCards.map(
1211
+ (card) => `
1212
+ <div class="card" style="--card-accent: ${card.accent}">
1213
+ <div class="card-label">${card.label}</div>
1214
+ <div class="card-value">${card.value}<span class="card-unit">${card.unit || ""}</span></div>
1215
+ <div class="card-detail">${card.detail}</div>
1216
+ </div>
1217
+ `
1218
+ ).join("");
1219
+ const chartsHtml = `
1220
+ <div class="charts-container">
1221
+ <div class="chart-wrapper">
1222
+ <div class="chart-title">${messages.speedChartTitle}</div>
1223
+ ${speedChart}
1224
+ </div>
1225
+ <div class="chart-wrapper">
1226
+ <div class="chart-title">${messages.htmlTpsDistribution}</div>
1227
+ ${tpsChart}
1228
+ </div>
1229
+ </div>
1230
+ `;
1231
+ const statsTableHtml = `
1232
+ <div class="table-wrapper">
1233
+ <table>
1234
+ <thead>
1235
+ <tr>
1236
+ <th>${messages.statsHeaders.metric}</th>
1237
+ <th>${messages.statsHeaders.mean}</th>
1238
+ <th>${messages.statsHeaders.p50}</th>
1239
+ <th>${messages.statsHeaders.p95}</th>
1240
+ <th>${messages.statsHeaders.p99}</th>
1241
+ <th>${messages.statsHeaders.min}</th>
1242
+ <th>${messages.statsHeaders.max}</th>
1243
+ </tr>
1244
+ </thead>
1245
+ <tbody>
1246
+ ${statsRows}
1247
+ </tbody>
1248
+ </table>
1249
+ </div>
1250
+ `;
1251
+ const detailsTableHtml = `
1252
+ <div class="table-wrapper">
1253
+ <table>
1254
+ <thead>
1255
+ <tr>
1256
+ <th>${messages.htmlRun}</th>
1257
+ <th>${messages.resultLabels.ttft}</th>
1258
+ <th>${messages.resultLabels.totalTime}</th>
1259
+ <th>${messages.resultLabels.totalTokens}</th>
1260
+ <th>${messages.resultLabels.averageSpeed}</th>
1261
+ <th>${messages.resultLabels.peakSpeed}</th>
1262
+ <th>${messages.resultLabels.peakTps}</th>
1263
+ </tr>
1264
+ </thead>
1265
+ <tbody>
1266
+ ${detailRows}
1267
+ </tbody>
1268
+ </table>
1269
+ </div>
1270
+ `;
1271
+ const data = {
1272
+ lang,
1273
+ title: messages.htmlTitle,
1274
+ reportTitle: messages.htmlReportTitle,
1275
+ testTimeLabel: messages.htmlTestTime,
1276
+ testTime,
1277
+ configSection: messages.htmlConfigSection,
1278
+ summarySection: messages.htmlSummarySection,
1279
+ chartsSection: messages.htmlChartsSection,
1280
+ statsTitle: messages.statsSummaryTitle(stats.sampleSize),
1281
+ detailsSection: messages.htmlDetailsSection,
1282
+ configGrid: configGridHtml,
1283
+ summaryCards: summaryCardsHtml,
1284
+ charts: chartsHtml,
1285
+ statsTable: statsTableHtml,
1286
+ detailsTable: detailsTableHtml
1287
+ };
1288
+ const template = loadTemplate();
1289
+ return replaceTemplate(template, data);
1290
+ }
1291
+
697
1292
  // src/index.ts
698
1293
  function getCliVersion() {
699
1294
  try {
700
- const currentDir = dirname(fileURLToPath(import.meta.url));
1295
+ const currentDir = dirname2(fileURLToPath2(import.meta.url));
701
1296
  const packagePath = join(currentDir, "..", "package.json");
702
- const packageJson = JSON.parse(readFileSync(packagePath, "utf-8"));
1297
+ const packageJson = JSON.parse(readFileSync2(packagePath, "utf-8"));
703
1298
  return packageJson.version ?? "unknown";
704
1299
  } catch {
705
1300
  return "unknown";
@@ -707,7 +1302,7 @@ function getCliVersion() {
707
1302
  }
708
1303
  var program = new Command();
709
1304
  program.name("token-speed-test").description("A CLI tool to test LLM API token output speed").version(getCliVersion());
710
- program.option("-k, --api-key <key>", "API Key (required)", "").option("-p, --provider <provider>", "API provider: anthropic or openai", "anthropic").option("-u, --url <url>", "Custom API endpoint URL").option("-m, --model <model>", "Model name").option("--max-tokens <number>", "Maximum output tokens", "1024").option("-r, --runs <number>", "Number of test runs", "3").option("--prompt <text>", "Test prompt").option("--lang <lang>", "Output language: zh or en", "zh").parse(process.argv);
1305
+ program.option("-k, --api-key <key>", "API Key (required)", "").option("-p, --provider <provider>", "API provider: anthropic or openai", "anthropic").option("-u, --url <url>", "Custom API endpoint URL").option("-m, --model <model>", "Model name").option("--max-tokens <number>", "Maximum output tokens", "1024").option("-r, --runs <number>", "Number of test runs", "3").option("--prompt <text>", "Test prompt").option("--lang <lang>", "Output language: zh or en", "zh").option("--html", "Generate HTML report").option("-o, --output <path>", "HTML report output path", "report.html").parse(process.argv);
711
1306
  var options = program.opts();
712
1307
  async function main() {
713
1308
  let messages = getMessages(DEFAULT_LANG);
@@ -751,6 +1346,31 @@ ${messages.runningTests}
751
1346
  console.log(chalk.green(`
752
1347
  ${messages.testComplete}
753
1348
  `));
1349
+ if (options.html) {
1350
+ const htmlPath = options.output;
1351
+ const htmlContent = generateHTMLReport({
1352
+ config,
1353
+ singleResults: allMetrics,
1354
+ stats,
1355
+ lang: config.lang,
1356
+ messages
1357
+ });
1358
+ try {
1359
+ await fsWriteFile(htmlPath, htmlContent, "utf-8");
1360
+ console.log(chalk.cyan(messages.htmlGenerated(htmlPath)));
1361
+ await open(htmlPath).catch(() => {
1362
+ console.warn(chalk.yellow(messages.htmlOpenError(htmlPath)));
1363
+ });
1364
+ } catch (err) {
1365
+ console.error(
1366
+ chalk.red(
1367
+ `
1368
+ ${messages.errorPrefix}: ${err instanceof Error ? err.message : String(err)}
1369
+ `
1370
+ )
1371
+ );
1372
+ }
1373
+ }
754
1374
  } catch (error) {
755
1375
  if (error instanceof Error) {
756
1376
  console.error(chalk.red(`