@linxiraos/pi-stats 1.1.26 → 1.2.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +12 -5
  2. package/build.ts +4 -0
  3. package/dist/client/favicon.ico +0 -0
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +1 -0
  6. package/dist/client/index.js +96 -96
  7. package/dist/types/aggregator.d.ts +4 -1
  8. package/dist/types/client/api.d.ts +5 -1
  9. package/dist/types/client/app/nav.d.ts +5 -2
  10. package/dist/types/client/data/formatters.d.ts +7 -2
  11. package/dist/types/client/data/live.d.ts +16 -11
  12. package/dist/types/client/data/query-store.d.ts +39 -0
  13. package/dist/types/client/data/query.d.ts +10 -5
  14. package/dist/types/client/data/range.d.ts +4 -3
  15. package/dist/types/client/i18n/format.d.ts +9 -0
  16. package/dist/types/client/i18n/index.d.ts +18 -0
  17. package/dist/types/client/i18n/messages/charts.d.ts +8 -0
  18. package/dist/types/client/i18n/messages/common.d.ts +153 -0
  19. package/dist/types/client/i18n/messages/costs.d.ts +92 -0
  20. package/dist/types/client/i18n/messages/en.d.ts +1255 -0
  21. package/dist/types/client/i18n/messages/errors.d.ts +78 -0
  22. package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
  23. package/dist/types/client/i18n/messages/gain.d.ts +54 -0
  24. package/dist/types/client/i18n/messages/models.d.ts +94 -0
  25. package/dist/types/client/i18n/messages/nav.d.ts +46 -0
  26. package/dist/types/client/i18n/messages/overview.d.ts +80 -0
  27. package/dist/types/client/i18n/messages/projects.d.ts +84 -0
  28. package/dist/types/client/i18n/messages/providers.d.ts +142 -0
  29. package/dist/types/client/i18n/messages/requests.d.ts +72 -0
  30. package/dist/types/client/i18n/messages/tools.d.ts +119 -0
  31. package/dist/types/client/i18n/messages/traces.d.ts +144 -0
  32. package/dist/types/client/i18n/messages/ui.d.ts +44 -0
  33. package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
  34. package/dist/types/client/i18n/types.d.ts +15 -0
  35. package/dist/types/client/traces/Minimap.d.ts +2 -0
  36. package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
  37. package/dist/types/client/traces/TranscriptList.d.ts +3 -1
  38. package/dist/types/db.d.ts +14 -21
  39. package/dist/types/embedded-client.d.ts +5 -0
  40. package/dist/types/frustration.d.ts +18 -9
  41. package/dist/types/live.d.ts +5 -3
  42. package/dist/types/locale.d.ts +16 -0
  43. package/dist/types/parser.d.ts +0 -17
  44. package/dist/types/rollup.d.ts +9 -1
  45. package/dist/types/server.d.ts +6 -0
  46. package/dist/types/shared-types.d.ts +7 -0
  47. package/dist/types/trace.d.ts +18 -6
  48. package/dist/types/types.d.ts +13 -1
  49. package/dist/types/user-metrics.d.ts +23 -1
  50. package/package.json +5 -5
  51. package/src/aggregator.ts +31 -11
  52. package/src/client/api.ts +40 -5
  53. package/src/client/app/LiveChip.tsx +14 -13
  54. package/src/client/app/Shell.tsx +19 -13
  55. package/src/client/app/ThemeToggle.tsx +4 -3
  56. package/src/client/app/nav.ts +19 -16
  57. package/src/client/data/formatters.ts +80 -13
  58. package/src/client/data/live.tsx +85 -45
  59. package/src/client/data/query-store.ts +108 -0
  60. package/src/client/data/query.ts +47 -56
  61. package/src/client/data/range.ts +34 -14
  62. package/src/client/data/series.ts +7 -1
  63. package/src/client/favicon.ico +0 -0
  64. package/src/client/i18n/format.ts +28 -0
  65. package/src/client/i18n/index.ts +53 -0
  66. package/src/client/i18n/messages/charts.ts +8 -0
  67. package/src/client/i18n/messages/common.ts +157 -0
  68. package/src/client/i18n/messages/costs.ts +104 -0
  69. package/src/client/i18n/messages/en.ts +40 -0
  70. package/src/client/i18n/messages/errors.ts +80 -0
  71. package/src/client/i18n/messages/frustration.ts +198 -0
  72. package/src/client/i18n/messages/gain.ts +54 -0
  73. package/src/client/i18n/messages/models.ts +94 -0
  74. package/src/client/i18n/messages/nav.ts +46 -0
  75. package/src/client/i18n/messages/overview.ts +90 -0
  76. package/src/client/i18n/messages/projects.ts +84 -0
  77. package/src/client/i18n/messages/providers.ts +165 -0
  78. package/src/client/i18n/messages/requests.ts +73 -0
  79. package/src/client/i18n/messages/tools.ts +120 -0
  80. package/src/client/i18n/messages/traces.ts +160 -0
  81. package/src/client/i18n/messages/ui.ts +46 -0
  82. package/src/client/i18n/messages/zh-CN.ts +43 -0
  83. package/src/client/i18n/types.ts +19 -0
  84. package/src/client/index.tsx +4 -0
  85. package/src/client/routes/CostsRoute.tsx +61 -48
  86. package/src/client/routes/ErrorsRoute.tsx +71 -47
  87. package/src/client/routes/FrustrationRoute.tsx +131 -116
  88. package/src/client/routes/GainRoute.tsx +42 -30
  89. package/src/client/routes/ModelsRoute.tsx +90 -73
  90. package/src/client/routes/OverviewRoute.tsx +109 -63
  91. package/src/client/routes/ProjectsRoute.tsx +73 -46
  92. package/src/client/routes/ProvidersRoute.tsx +151 -114
  93. package/src/client/routes/RequestsRoute.tsx +69 -46
  94. package/src/client/routes/ToolsRoute.tsx +108 -94
  95. package/src/client/routes/TracesRoute.tsx +25 -23
  96. package/src/client/styles.css +5 -2
  97. package/src/client/traces/AggregatesPanel.tsx +19 -8
  98. package/src/client/traces/Minimap.tsx +65 -27
  99. package/src/client/traces/SpanDrawer.tsx +46 -21
  100. package/src/client/traces/SummaryStrip.tsx +27 -11
  101. package/src/client/traces/TimelineCanvas.tsx +245 -116
  102. package/src/client/traces/TraceView.tsx +51 -36
  103. package/src/client/traces/TranscriptList.tsx +20 -11
  104. package/src/client/traces/traces.css +26 -0
  105. package/src/client/ui/JsonBlock.tsx +3 -2
  106. package/src/client/ui/Modal.tsx +3 -2
  107. package/src/client/ui/RequestDrawer.tsx +33 -30
  108. package/src/client/ui/SearchInput.tsx +2 -1
  109. package/src/client/ui/States.tsx +3 -2
  110. package/src/client/ui/Table.tsx +7 -3
  111. package/src/db.ts +150 -135
  112. package/src/embedded-client.ts +9 -0
  113. package/src/frustration.ts +9 -30
  114. package/src/index.ts +2 -1
  115. package/src/live.ts +36 -11
  116. package/src/locale.ts +29 -0
  117. package/src/parser.ts +37 -72
  118. package/src/port-conflict.ts +1 -1
  119. package/src/prompts/judge/frustration.json +19 -0
  120. package/src/rollup.ts +41 -20
  121. package/src/server.ts +67 -27
  122. package/src/shared-types.ts +7 -0
  123. package/src/trace.ts +54 -40
  124. package/src/types.ts +13 -1
  125. package/src/user-metrics.ts +220 -6
@@ -1,5 +1,6 @@
1
1
  import { useMemo, useState } from "react";
2
2
  import { getGainDashboardStats } from "../api";
3
+ import { t } from "../i18n";
3
4
  import { Chart, type ChartSeries, Legend } from "../charts";
4
5
  import { formatBytes, formatCompact, formatInteger, formatPercent } from "../data/formatters";
5
6
  import { useQuery } from "../data/query";
@@ -39,7 +40,9 @@ interface SourceRow extends GainSourceTotals {
39
40
 
40
41
  export function GainRoute({ active, range }: GainRouteProps) {
41
42
  const [project, setProject] = useState<string | null>(null);
42
- const gain = useQuery(["gain", range, project], () => getGainDashboardStats(range, project), { enabled: active });
43
+ const gain = useQuery(["gain", range, project], ({ signal }) => getGainDashboardStats(range, project, signal), {
44
+ enabled: active,
45
+ });
43
46
  const meta = rangeMeta(range);
44
47
  const data = gain.data;
45
48
 
@@ -69,13 +72,12 @@ export function GainRoute({ active, range }: GainRouteProps) {
69
72
  const projects = data?.projects ?? [];
70
73
  // Keep the chosen project selectable even when the current range never saw it.
71
74
  const projectOptions = project !== null && !projects.includes(project) ? [project, ...projects] : projects;
72
- const scope = project ? ` for ${project}` : "";
73
75
 
74
76
  const chartSeries: ChartSeries[] = [
75
- { key: "daily", label: "Saved per day", color: "var(--chart-primary)", values: series.daily },
77
+ { key: "daily", label: t("gain.saved-per-day"), color: "var(--chart-primary)", values: series.daily },
76
78
  {
77
79
  key: "cumulative",
78
- label: "Cumulative",
80
+ label: t("gain.cumulative"),
79
81
  color: "var(--chart-secondary)",
80
82
  values: series.cumulative,
81
83
  kind: "line",
@@ -86,18 +88,22 @@ export function GainRoute({ active, range }: GainRouteProps) {
86
88
  return (
87
89
  <div className="page">
88
90
  <PageHeader
89
- title="Gain"
90
- description={`Tokens snapcompact kept out of context${scope} in ${meta.windowLabel}.`}
91
+ title={t("gain.title")}
92
+ description={
93
+ project
94
+ ? t("gain.description-scoped", { project, window: t(meta.windowKey) })
95
+ : t("gain.description", { window: t(meta.windowKey) })
96
+ }
91
97
  actions={
92
98
  projectOptions.length > 0 && (
93
99
  <select
94
100
  className="input"
95
- aria-label="Project"
101
+ aria-label={t("common.project")}
96
102
  value={project ?? ""}
97
103
  onChange={e => setProject(e.target.value || null)}
98
104
  style={{ maxWidth: 320 }}
99
105
  >
100
- <option value="">All projects</option>
106
+ <option value="">{t("gain.all-projects")}</option>
101
107
  {projectOptions.map(p => (
102
108
  <option key={p} value={p}>
103
109
  {p}
@@ -115,12 +121,12 @@ export function GainRoute({ active, range }: GainRouteProps) {
115
121
  empty={
116
122
  <Card>
117
123
  <EmptyState
118
- title={`No savings recorded${scope} in ${meta.windowLabel}`}
119
- hint={
120
- range === "all"
121
- ? "Savings appear here once snapcompact compacts tool output."
122
- : "Try a longer range."
124
+ title={
125
+ project
126
+ ? t("gain.empty-title-scoped", { project, window: t(meta.windowKey) })
127
+ : t("gain.empty-title", { window: t(meta.windowKey) })
123
128
  }
129
+ hint={range === "all" ? t("gain.empty-hint-all") : t("gain.empty-hint-longer-range")}
124
130
  />
125
131
  </Card>
126
132
  }
@@ -130,31 +136,31 @@ export function GainRoute({ active, range }: GainRouteProps) {
130
136
  <div data-stale={gain.stale}>
131
137
  <StatGrid min={180}>
132
138
  <Stat
133
- label="Saved tokens"
139
+ label={t("gain.saved-tokens")}
134
140
  value={formatCompact(overall.savedTokens)}
135
141
  hint={formatInteger(overall.savedTokens)}
136
142
  spark={series.daily}
137
143
  />
138
- <Stat label="Saved bytes" value={formatBytes(overall.savedBytes)} />
144
+ <Stat label={t("gain.saved-bytes")} value={formatBytes(overall.savedBytes)} />
139
145
  <Stat
140
- label="Reduction"
141
- title="Saved bytes ÷ original bytes, when the original size is known"
146
+ label={t("gain.reduction")}
147
+ title={t("gain.reduction-title")}
142
148
  value={overall.reductionPercent !== null ? formatPercent(overall.reductionPercent) : "–"}
143
- hint={overall.reductionPercent === null ? "original size not recorded" : undefined}
149
+ hint={overall.reductionPercent === null ? t("gain.reduction-unknown") : undefined}
144
150
  />
145
- <Stat label="Hits" value={formatInteger(overall.hits)} />
151
+ <Stat label={t("gain.hits")} value={formatInteger(overall.hits)} />
146
152
  <Stat
147
- label="Saved per hit"
153
+ label={t("gain.saved-per-hit")}
148
154
  value={overall.hits > 0 ? formatCompact(overall.savedTokens / overall.hits) : "–"}
149
- hint="tokens"
155
+ hint={t("common.tokens")}
150
156
  />
151
157
  </StatGrid>
152
158
  </div>
153
159
 
154
160
  <Card
155
161
  index={1}
156
- title="Savings over time"
157
- description="Tokens saved per UTC day, with the running total"
162
+ title={t("gain.savings-over-time")}
163
+ description={t("gain.savings-over-time-description")}
158
164
  actions={<Legend items={chartSeries.map(s => ({ key: s.key, label: s.label, color: s.color }))} />}
159
165
  stale={gain.stale}
160
166
  >
@@ -168,7 +174,13 @@ export function GainRoute({ active, range }: GainRouteProps) {
168
174
  />
169
175
  </Card>
170
176
 
171
- <Card index={2} title="By source" description="Savings per subsystem" flush stale={gain.stale}>
177
+ <Card
178
+ index={2}
179
+ title={t("gain.by-source")}
180
+ description={t("gain.by-source-description")}
181
+ flush
182
+ stale={gain.stale}
183
+ >
172
184
  <Table rows={sourceRows} rowKey={row => row.source} columns={SOURCE_COLUMNS} />
173
185
  </Card>
174
186
  </>
@@ -181,13 +193,13 @@ export function GainRoute({ active, range }: GainRouteProps) {
181
193
  const SOURCE_COLUMNS: Column<SourceRow>[] = [
182
194
  {
183
195
  key: "source",
184
- header: "Source",
196
+ header: t("gain.source"),
185
197
  sort: row => row.source,
186
198
  render: row => <span className="cell-primary">{SOURCE_LABEL[row.source]}</span>,
187
199
  },
188
200
  {
189
201
  key: "tokens",
190
- header: "Saved tokens",
202
+ header: t("gain.saved-tokens"),
191
203
  align: "right",
192
204
  sort: row => row.savedTokens,
193
205
  render: row => (
@@ -198,28 +210,28 @@ const SOURCE_COLUMNS: Column<SourceRow>[] = [
198
210
  },
199
211
  {
200
212
  key: "share",
201
- header: "Share",
213
+ header: t("common.share"),
202
214
  align: "right",
203
215
  sort: row => row.share,
204
216
  render: row => <span className="num muted">{formatPercent(row.share)}</span>,
205
217
  },
206
218
  {
207
219
  key: "bytes",
208
- header: "Saved bytes",
220
+ header: t("gain.saved-bytes"),
209
221
  align: "right",
210
222
  sort: row => row.savedBytes,
211
223
  render: row => <span className="num">{formatBytes(row.savedBytes)}</span>,
212
224
  },
213
225
  {
214
226
  key: "hits",
215
- header: "Hits",
227
+ header: t("gain.hits"),
216
228
  align: "right",
217
229
  sort: row => row.hits,
218
230
  render: row => <span className="num">{formatInteger(row.hits)}</span>,
219
231
  },
220
232
  {
221
233
  key: "reduction",
222
- header: "Reduction",
234
+ header: t("gain.reduction"),
223
235
  align: "right",
224
236
  sort: row => row.reductionPercent ?? -1,
225
237
  render: row => (
@@ -2,11 +2,13 @@ import "./models.css";
2
2
  import { ChevronRight } from "lucide-react";
3
3
  import { useMemo, useState } from "react";
4
4
  import { getModelDashboardStats } from "../api";
5
+ import { t } from "../i18n";
5
6
  import { type ChartSeries, Legend, Sparkline, TimeChart, useHiddenSeries } from "../charts";
6
7
  import { buildModelColorLookup, modelKey, OTHER_COLOR } from "../data/colors";
7
8
  import {
8
9
  formatCompact,
9
10
  formatDurationMs,
11
+ formatErrorRate,
10
12
  formatEstimatedCost,
11
13
  formatInteger,
12
14
  formatPercent,
@@ -51,30 +53,34 @@ export interface ModelsRouteProps {
51
53
  type ShareMode = "share" | "requests";
52
54
 
53
55
  const SHARE_OPTIONS = [
54
- { value: "share" as const, label: "Share" },
55
- { value: "requests" as const, label: "Requests" },
56
+ { value: "share" as const, label: t("common.share") },
57
+ { value: "requests" as const, label: t("common.requests") },
56
58
  ];
57
59
 
58
60
  /** Models plotted individually in the share chart; the rest fold into "Other". */
59
61
  const SHARE_LIMIT = 6;
60
62
 
61
63
  export function ModelsRoute({ active, range }: ModelsRouteProps) {
62
- const query = useQuery(["models", range], () => getModelDashboardStats(range), { enabled: active });
64
+ const query = useQuery(["models", range], ({ signal }) => getModelDashboardStats(range, signal), {
65
+ enabled: active,
66
+ });
63
67
  const meta = rangeMeta(range);
64
68
  const [mode, setMode] = useState<ShareMode>("share");
65
69
  const [hidden, toggleSeries] = useHiddenSeries();
66
70
  const [expandedKey, setExpandedKey] = useState<string | null>(null);
67
71
  const view = useMemo(() => (query.data ? buildModelsView(query.data, range) : null), [query.data, range]);
68
72
  const bucketWord = bucketName(meta.bucketMs);
73
+ // Stable columns: a fresh array would make Table re-sort on every render.
74
+ const columns = useMemo(
75
+ () => (view ? buildModelColumns(view, expandedKey, bucketWord) : []),
76
+ [view, expandedKey, bucketWord],
77
+ );
69
78
  const isEmpty = (data: ModelDashboardStats) => data.byModel.length === 0;
70
- const empty = <EmptyState title="No model usage in this range" hint="Pick a wider range in the top bar." />;
79
+ const empty = <EmptyState title={t("models.empty-title")} hint={t("models.empty-hint")} />;
71
80
 
72
81
  return (
73
82
  <div className="page">
74
- <PageHeader
75
- title="Models"
76
- description={`Which models did the work in ${meta.windowLabel}, and how fast they answered.`}
77
- />
83
+ <PageHeader title={t("common.models")} description={t("models.subtitle", { window: t(meta.windowKey) })} />
78
84
 
79
85
  <QueryView
80
86
  query={query}
@@ -87,32 +93,41 @@ export function ModelsRoute({ active, range }: ModelsRouteProps) {
87
93
  <div data-stale={query.stale}>
88
94
  <StatGrid min={200}>
89
95
  <Stat
90
- label="Models used"
96
+ label={t("models.used")}
91
97
  value={formatInteger(view.models.length)}
92
- hint={`across ${formatInteger(view.providerCount)} provider${view.providerCount === 1 ? "" : "s"}`}
98
+ hint={
99
+ view.providerCount === 1
100
+ ? t("models.across-providers-one", { count: formatInteger(view.providerCount) })
101
+ : t("models.across-providers", { count: formatInteger(view.providerCount) })
102
+ }
93
103
  />
94
104
  <Stat
95
- label="Most used"
105
+ label={t("models.most-used")}
96
106
  title={view.top ? `${view.top.model} (${view.top.provider})` : undefined}
97
107
  value={view.top ? view.top.model : "–"}
98
108
  hint={
99
109
  view.top
100
- ? `${formatPercent(view.top.totalRequests / Math.max(1, view.totalRequests))} of requests · ${view.top.provider}`
110
+ ? t("models.most-used-hint", {
111
+ share: formatPercent(view.top.totalRequests / Math.max(1, view.totalRequests)),
112
+ provider: view.top.provider,
113
+ })
101
114
  : undefined
102
115
  }
103
116
  />
104
117
  <Stat
105
- label="Requests"
118
+ label={t("common.requests")}
106
119
  value={formatInteger(view.totalRequests)}
107
- hint={`${formatInteger(view.failedRequests)} failed`}
120
+ hint={t("models.failed-n", { count: formatInteger(view.failedRequests) })}
108
121
  spark={view.requestTotals}
109
122
  />
110
123
  <Stat
111
- label="API-equivalent cost"
112
- title="What this usage would cost at public API rates"
124
+ label={t("models.api-cost")}
125
+ title={t("models.api-cost-title")}
113
126
  value={formatEstimatedCost(view.totalCost, view.unpricedRequests)}
114
127
  hint={
115
- view.unpricedRequests > 0 ? `${formatInteger(view.unpricedRequests)} unpriced` : undefined
128
+ view.unpricedRequests > 0
129
+ ? t("models.unpriced-n", { count: formatInteger(view.unpricedRequests) })
130
+ : undefined
116
131
  }
117
132
  />
118
133
  </StatGrid>
@@ -123,11 +138,11 @@ export function ModelsRoute({ active, range }: ModelsRouteProps) {
123
138
 
124
139
  <Card
125
140
  index={1}
126
- title="Request share"
141
+ title={t("models.request-share")}
127
142
  description={
128
143
  mode === "share"
129
- ? `Each model's share of requests per ${bucketWord}`
130
- : `Requests per ${bucketWord}, stacked by model`
144
+ ? t("models.share-desc", { bucket: bucketWord })
145
+ : t("models.count-desc", { bucket: bucketWord })
131
146
  }
132
147
  actions={
133
148
  <Segmented
@@ -135,7 +150,7 @@ export function ModelsRoute({ active, range }: ModelsRouteProps) {
135
150
  options={SHARE_OPTIONS}
136
151
  value={mode}
137
152
  onChange={setMode}
138
- aria-label="Share chart mode"
153
+ aria-label={t("models.share-mode-aria")}
139
154
  />
140
155
  }
141
156
  stale={query.stale}
@@ -171,20 +186,14 @@ export function ModelsRoute({ active, range }: ModelsRouteProps) {
171
186
  </QueryView>
172
187
  </Card>
173
188
 
174
- <Card
175
- index={2}
176
- title="All models"
177
- description="Click a row for latency and throughput over time"
178
- stale={query.stale}
179
- flush
180
- >
189
+ <Card index={2} title={t("models.all-models")} description={t("models.table-desc")} stale={query.stale} flush>
181
190
  <QueryView query={query} skeleton={<TableSkeleton rows={8} />} isEmpty={isEmpty} empty={empty}>
182
191
  {() =>
183
192
  view && (
184
193
  <Table
185
194
  rows={view.models}
186
195
  rowKey={row => modelKey(row.model, row.provider)}
187
- columns={buildModelColumns(view, expandedKey, bucketWord)}
196
+ columns={columns}
188
197
  initialSort={{ key: "requests", dir: "desc" }}
189
198
  limit={25}
190
199
  onRowClick={row => {
@@ -322,7 +331,7 @@ function buildModelColumns(view: ModelsView, expandedKey: string | null, bucketW
322
331
  },
323
332
  {
324
333
  key: "model",
325
- header: "Model",
334
+ header: t("common.model"),
326
335
  sort: row => row.model,
327
336
  render: row => (
328
337
  <LabelCell
@@ -334,7 +343,7 @@ function buildModelColumns(view: ModelsView, expandedKey: string | null, bucketW
334
343
  },
335
344
  {
336
345
  key: "requests",
337
- header: "Requests",
346
+ header: t("common.requests"),
338
347
  align: "right",
339
348
  sort: row => row.totalRequests,
340
349
  render: row => (
@@ -348,16 +357,16 @@ function buildModelColumns(view: ModelsView, expandedKey: string | null, bucketW
348
357
  },
349
358
  {
350
359
  key: "cost",
351
- header: "Cost",
352
- title: "API-equivalent estimate at public rates",
360
+ header: t("common.cost"),
361
+ title: t("models.cost-title"),
353
362
  align: "right",
354
363
  sort: row => row.totalCost,
355
364
  render: row => <span className="num">{formatEstimatedCost(row.totalCost, row.unpricedRequests)}</span>,
356
365
  },
357
366
  {
358
367
  key: "tokens",
359
- header: "Tokens",
360
- title: "Uncached input + cache reads + cache writes + output",
368
+ header: t("common.tokens"),
369
+ title: t("models.tokens-title"),
361
370
  align: "right",
362
371
  sort: row => sumConversationTokens(row),
363
372
  render: row => {
@@ -371,48 +380,48 @@ function buildModelColumns(view: ModelsView, expandedKey: string | null, bucketW
371
380
  },
372
381
  {
373
382
  key: "cache",
374
- header: "Cache rate",
375
- title: "Cache reads ÷ (uncached input + cache reads)",
383
+ header: t("models.cache-rate"),
384
+ title: t("models.cache-title"),
376
385
  align: "right",
377
386
  sort: row => row.cacheRate,
378
387
  render: row => <span className="num">{formatPercent(row.cacheRate)}</span>,
379
388
  },
380
389
  {
381
390
  key: "errors",
382
- header: "Errors",
391
+ header: t("models.errors"),
383
392
  align: "right",
384
393
  sort: row => row.errorRate,
385
394
  render: row =>
386
395
  row.failedRequests === 0 ? (
387
396
  <span className="num dim">0%</span>
388
397
  ) : (
389
- <span title={`${formatInteger(row.failedRequests)} failed`}>
398
+ <span title={t("models.failed-n", { count: formatInteger(row.failedRequests) })}>
390
399
  <Badge tone={errorRateTone(row.errorRate)} mono>
391
- {formatPercent(row.errorRate)}
400
+ {formatErrorRate(row.errorRate)}
392
401
  </Badge>
393
402
  </span>
394
403
  ),
395
404
  },
396
405
  {
397
406
  key: "tps",
398
- header: "Tokens/s",
399
- title: "Average output tokens per second",
407
+ header: t("common.tokens-per-second"),
408
+ title: t("models.tps-title"),
400
409
  align: "right",
401
410
  sort: row => row.avgTokensPerSecond ?? -1,
402
411
  render: row => <span className="num">{formatTokensPerSecond(row.avgTokensPerSecond)}</span>,
403
412
  },
404
413
  {
405
414
  key: "ttft",
406
- header: "TTFT",
407
- title: "Average time to first token",
415
+ header: t("models.ttft"),
416
+ title: t("models.ttft-title"),
408
417
  align: "right",
409
418
  sort: row => row.avgTtft ?? -1,
410
419
  render: row => <span className="num">{formatDurationMs(row.avgTtft)}</span>,
411
420
  },
412
421
  {
413
422
  key: "trend",
414
- header: "Trend",
415
- title: `Requests per ${bucketWord}`,
423
+ header: t("models.trend"),
424
+ title: t("models.trend-title", { bucket: bucketWord }),
416
425
  width: 112,
417
426
  render: row => {
418
427
  const key = modelKey(row.model, row.provider);
@@ -449,14 +458,14 @@ function ModelDetail({
449
458
  const series: ChartSeries[] = [
450
459
  {
451
460
  key: "tps",
452
- label: "Tokens/s",
461
+ label: t("common.tokens-per-second"),
453
462
  color,
454
463
  kind: points.length > 1 ? "line" : "bars",
455
464
  values: points.map(p => p.avgTokensPerSecond),
456
465
  },
457
466
  {
458
467
  key: "ttft",
459
- label: "TTFT",
468
+ label: t("models.ttft"),
460
469
  color: OTHER_COLOR,
461
470
  kind: points.length > 1 ? "line" : "bars",
462
471
  axis: "right",
@@ -469,23 +478,25 @@ function ModelDetail({
469
478
  <div className="models-detail">
470
479
  <div className="models-detail-facts">
471
480
  <div>
472
- <div className="section-label">Efficiency</div>
481
+ <div className="section-label">{t("models.efficiency")}</div>
473
482
  <KeyValues
474
483
  items={[
475
484
  {
476
485
  key: "err",
477
- label: "Error rate",
486
+ label: t("common.error-rate"),
478
487
  value: (
479
488
  <span className={`tone-${errorRateTone(model.errorRate)}`}>
480
- {formatPercent(model.errorRate)}{" "}
481
- <span className="dim">({formatInteger(model.failedRequests)} failed)</span>
489
+ {formatErrorRate(model.errorRate)}{" "}
490
+ <span className="dim">
491
+ ({t("models.failed-n", { count: formatInteger(model.failedRequests) })})
492
+ </span>
482
493
  </span>
483
494
  ),
484
495
  },
485
- { key: "cache", label: "Cache rate", value: formatPercent(model.cacheRate) },
496
+ { key: "cache", label: t("models.cache-rate"), value: formatPercent(model.cacheRate) },
486
497
  {
487
498
  key: "savings",
488
- label: "Cache savings",
499
+ label: t("models.cache-savings"),
489
500
  value: (
490
501
  <span className={model.cacheSavings < 0 ? "tone-bad" : undefined}>
491
502
  {formatPercent(model.cacheSavings)}
@@ -494,42 +505,48 @@ function ModelDetail({
494
505
  },
495
506
  {
496
507
  key: "premium",
497
- label: "Premium requests",
508
+ label: t("common.premium-requests"),
498
509
  value: formatInteger(Math.round(model.totalPremiumRequests * 100) / 100),
499
510
  },
500
511
  ]}
501
512
  />
502
513
  </div>
503
514
  <div>
504
- <div className="section-label">Latency</div>
515
+ <div className="section-label">{t("models.latency")}</div>
505
516
  <KeyValues
506
517
  items={[
507
- { key: "dur", label: "Avg duration", value: formatDurationMs(model.avgDuration) },
508
- { key: "ttft", label: "Avg TTFT", value: formatDurationMs(model.avgTtft) },
509
- { key: "tps", label: "Tokens/s", value: formatTokensPerSecond(model.avgTokensPerSecond) },
518
+ { key: "dur", label: t("models.avg-duration"), value: formatDurationMs(model.avgDuration) },
519
+ { key: "ttft", label: t("models.avg-ttft"), value: formatDurationMs(model.avgTtft) },
520
+ {
521
+ key: "tps",
522
+ label: t("common.tokens-per-second"),
523
+ value: formatTokensPerSecond(model.avgTokensPerSecond),
524
+ },
510
525
  ]}
511
526
  />
512
527
  </div>
513
528
  <div>
514
- <div className="section-label">Tokens</div>
529
+ <div className="section-label">{t("common.tokens")}</div>
515
530
  <KeyValues
516
531
  items={[
517
- { key: "in", label: "Uncached input", value: formatCompact(model.totalInputTokens) },
518
- { key: "cr", label: "Cache read", value: formatCompact(model.totalCacheReadTokens) },
519
- { key: "cw", label: "Cache write", value: formatCompact(model.totalCacheWriteTokens) },
520
- { key: "out", label: "Output", value: formatCompact(model.totalOutputTokens) },
532
+ { key: "in", label: t("models.uncached-input"), value: formatCompact(model.totalInputTokens) },
533
+ { key: "cr", label: t("common.cache-read"), value: formatCompact(model.totalCacheReadTokens) },
534
+ { key: "cw", label: t("common.cache-write"), value: formatCompact(model.totalCacheWriteTokens) },
535
+ { key: "out", label: t("common.output"), value: formatCompact(model.totalOutputTokens) },
521
536
  ]}
522
537
  />
523
538
  </div>
524
539
  <div className="micro dim">
525
- First seen {formatRelativeTime(model.firstTimestamp)} · last seen{" "}
526
- {formatRelativeTime(model.lastTimestamp)}
540
+ {t("models.first-last-seen", {
541
+ first: formatRelativeTime(model.firstTimestamp),
542
+ last: formatRelativeTime(model.lastTimestamp),
543
+ })}
527
544
  </div>
528
545
  </div>
529
546
  <div className="models-detail-chart">
530
547
  <div className="row" style={{ justifyContent: "space-between" }}>
531
548
  <div className="section-label" style={{ marginBottom: 0 }}>
532
- Performance per active {bucketWord}
549
+ {t("models.performance-per-bucket", { bucket: bucketWord })}
533
550
  </div>
534
551
  <Legend
535
552
  items={series.map(s => ({ key: s.key, label: s.label, color: s.color }))}
@@ -538,7 +555,7 @@ function ModelDetail({
538
555
  />
539
556
  </div>
540
557
  {points.length === 0 ? (
541
- <EmptyState title="No performance samples" hint="Timing is recorded for streamed responses." />
558
+ <EmptyState title={t("models.no-samples-title")} hint={t("models.no-samples-hint")} />
542
559
  ) : (
543
560
  <TimeChart
544
561
  buckets={points.map(p => p.timestamp)}
@@ -548,11 +565,11 @@ function ModelDetail({
548
565
  stacked={false}
549
566
  height={240}
550
567
  format={formatCompact}
551
- formatTooltip={v => `${formatTokensPerSecond(v)} tok/s`}
568
+ formatTooltip={v => t("models.tok-per-second", { value: formatTokensPerSecond(v) })}
552
569
  formatRight={v => `${Number(v.toFixed(2))}s`}
553
570
  tooltipExtra={i => (
554
571
  <div className="chart-tooltip-row chart-tooltip-total">
555
- <span className="chart-tooltip-label">Requests</span>
572
+ <span className="chart-tooltip-label">{t("common.requests")}</span>
556
573
  <span className="chart-tooltip-value">{formatInteger(points[i].requests)}</span>
557
574
  </div>
558
575
  )}
@@ -564,9 +581,9 @@ function ModelDetail({
564
581
  }
565
582
 
566
583
  function bucketName(bucketMs: number): string {
567
- if (bucketMs < 3_600_000) return "5 minutes";
568
- if (bucketMs < 86_400_000) return "hour";
569
- return "day";
584
+ if (bucketMs < 3_600_000) return t("models.bucket-5m");
585
+ if (bucketMs < 86_400_000) return t("models.bucket-hour");
586
+ return t("models.bucket-day");
570
587
  }
571
588
 
572
589
  function sumValues(values: readonly (number | null)[]): number {