@tonyclaw/agent-inspector 2.0.28 → 2.0.29

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 (44) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-CicCP3Hb.js → CompareDrawer-DcqxlgSQ.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-D650kQlv.js +115 -0
  4. package/.output/public/assets/{ReplayDialog-Bc8q3ujm.js → ReplayDialog-CBcnPgx1.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-Coyy3zcH.js → RequestAnatomy-DSgSqCYt.js} +1 -1
  6. package/.output/public/assets/{ResponseView-BWJzgh0c.js → ResponseView-CjqzBSoF.js} +1 -1
  7. package/.output/public/assets/{StreamingChunkSequence-BSC3uuE5.js → StreamingChunkSequence-DdBBKolI.js} +1 -1
  8. package/.output/public/assets/_sessionId-wctOoGh_.js +1 -0
  9. package/.output/public/assets/index-BhQGSdhG.js +1 -0
  10. package/.output/public/assets/{index-DsiKfWCp.css → index-CwlHPmgL.css} +1 -1
  11. package/.output/public/assets/{main-Brj0Gn91.js → main-B6OLZCp9.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +13 -13
  13. package/.output/server/{_sessionId-DDdyKVG-.mjs → _sessionId-CCfKJJ19.mjs} +2 -2
  14. package/.output/server/_ssr/{CompareDrawer-XSExe2ml.mjs → CompareDrawer-uaPI5R6e.mjs} +2 -2
  15. package/.output/server/_ssr/{ProxyViewerContainer-B5pIiBZN.mjs → ProxyViewerContainer-BV2nIxXy.mjs} +83 -20
  16. package/.output/server/_ssr/{ReplayDialog-BOAu0ric.mjs → ReplayDialog-eE2Hy2Nl.mjs} +4 -4
  17. package/.output/server/_ssr/{RequestAnatomy-C8Q5lozD.mjs → RequestAnatomy-B7vwWo57.mjs} +2 -2
  18. package/.output/server/_ssr/{ResponseView-a_iJfPUF.mjs → ResponseView-Bm6Qp8mo.mjs} +2 -2
  19. package/.output/server/_ssr/{StreamingChunkSequence-YMKC0j8U.mjs → StreamingChunkSequence-CbAJKJQx.mjs} +2 -2
  20. package/.output/server/_ssr/{index-CkDwBPzI.mjs → index-u14_Aj60.mjs} +2 -2
  21. package/.output/server/_ssr/index.mjs +2 -2
  22. package/.output/server/_ssr/{router-DXOXdFFM.mjs → router-qgeGUp1k.mjs} +161 -18
  23. package/.output/server/{_tanstack-start-manifest_v-DPf3uJys.mjs → _tanstack-start-manifest_v-BrfUXnre.mjs} +1 -1
  24. package/.output/server/index.mjs +66 -66
  25. package/package.json +1 -1
  26. package/src/components/ProxyViewer.tsx +3 -0
  27. package/src/components/providers/ProviderCard.tsx +32 -0
  28. package/src/components/proxy-viewer/LogEntryHeader.tsx +38 -1
  29. package/src/lib/providerTestContract.ts +3 -0
  30. package/src/lib/sessionInfoContract.ts +3 -0
  31. package/src/mcp/toolHandlers.ts +3 -0
  32. package/src/proxy/evidenceAnalysis.ts +5 -0
  33. package/src/proxy/evidenceExporter.ts +8 -3
  34. package/src/proxy/handler.ts +8 -1
  35. package/src/proxy/logFinalizer.ts +36 -0
  36. package/src/proxy/schemas.ts +3 -0
  37. package/src/proxy/sessionInfo.ts +3 -0
  38. package/src/proxy/store.ts +3 -0
  39. package/src/proxy/streamTiming.ts +31 -0
  40. package/src/routes/api/logs.$id.replay.ts +25 -2
  41. package/src/routes/api/providers.$providerId.test.log.ts +34 -2
  42. package/.output/public/assets/ProxyViewerContainer-BccuA6p5.js +0 -115
  43. package/.output/public/assets/_sessionId-9rEF0uSE.js +0 -1
  44. package/.output/public/assets/index-B9_VaAWl.js +0 -1
@@ -35,103 +35,103 @@ const headers = ((m) => function headersRouteRule(event) {
35
35
  }
36
36
  });
37
37
  const assets = {
38
- "/assets/index-B9_VaAWl.js": {
39
- "type": "text/javascript; charset=utf-8",
40
- "etag": '"74-wegwMejcMaCe2qDEma7y6qUh/6U"',
41
- "mtime": "2026-06-30T03:21:19.568Z",
42
- "size": 116,
43
- "path": "../public/assets/index-B9_VaAWl.js"
44
- },
45
38
  "/assets/alibaba-TTwafVwX.svg": {
46
39
  "type": "image/svg+xml",
47
40
  "etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
48
- "mtime": "2026-06-30T03:21:19.568Z",
41
+ "mtime": "2026-06-30T09:01:48.556Z",
49
42
  "size": 5915,
50
43
  "path": "../public/assets/alibaba-TTwafVwX.svg"
51
44
  },
52
- "/assets/CompareDrawer-CicCP3Hb.js": {
45
+ "/assets/index-BhQGSdhG.js": {
53
46
  "type": "text/javascript; charset=utf-8",
54
- "etag": '"49fe-CmOMF3QSwAcX6NWjQdoZrIdyoIw"',
55
- "mtime": "2026-06-30T03:21:19.570Z",
56
- "size": 18942,
57
- "path": "../public/assets/CompareDrawer-CicCP3Hb.js"
47
+ "etag": '"74-1e7gU3IOVaEjblxPQfgH61GRjlw"',
48
+ "mtime": "2026-06-30T09:01:48.556Z",
49
+ "size": 116,
50
+ "path": "../public/assets/index-BhQGSdhG.js"
58
51
  },
59
- "/assets/index-DsiKfWCp.css": {
60
- "type": "text/css; charset=utf-8",
61
- "etag": '"1a3d5-pnLJEAiyUUKlRw5OUnvwf1dCQeI"',
62
- "mtime": "2026-06-30T03:21:19.568Z",
63
- "size": 107477,
64
- "path": "../public/assets/index-DsiKfWCp.css"
52
+ "/assets/CompareDrawer-DcqxlgSQ.js": {
53
+ "type": "text/javascript; charset=utf-8",
54
+ "etag": '"49fe-+D2I+/9sd7EYksKARn3HVY2eJf0"',
55
+ "mtime": "2026-06-30T09:01:48.556Z",
56
+ "size": 18942,
57
+ "path": "../public/assets/CompareDrawer-DcqxlgSQ.js"
65
58
  },
66
59
  "/assets/minimax-BPMzvuL-.jpeg": {
67
60
  "type": "image/jpeg",
68
61
  "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
69
- "mtime": "2026-06-30T03:21:19.568Z",
62
+ "mtime": "2026-06-30T09:01:48.556Z",
70
63
  "size": 6918,
71
64
  "path": "../public/assets/minimax-BPMzvuL-.jpeg"
72
65
  },
73
- "/assets/qwen-CONDcHqt.png": {
74
- "type": "image/png",
75
- "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
76
- "mtime": "2026-06-30T03:21:19.568Z",
77
- "size": 357059,
78
- "path": "../public/assets/qwen-CONDcHqt.png"
66
+ "/assets/index-CwlHPmgL.css": {
67
+ "type": "text/css; charset=utf-8",
68
+ "etag": '"1a3f0-E2f0kOdSBi1RhjoK38+CSij/apg"',
69
+ "mtime": "2026-06-30T09:01:48.556Z",
70
+ "size": 107504,
71
+ "path": "../public/assets/index-CwlHPmgL.css"
79
72
  },
80
- "/assets/ReplayDialog-Bc8q3ujm.js": {
73
+ "/assets/ReplayDialog-CBcnPgx1.js": {
81
74
  "type": "text/javascript; charset=utf-8",
82
- "etag": '"318c-QS16LlsGhExlRZzuuDmYzqWLBN0"',
83
- "mtime": "2026-06-30T03:21:19.570Z",
75
+ "etag": '"318c-Wq35vSCO67O3pnw6riReoXT9gOg"',
76
+ "mtime": "2026-06-30T09:01:48.557Z",
84
77
  "size": 12684,
85
- "path": "../public/assets/ReplayDialog-Bc8q3ujm.js"
78
+ "path": "../public/assets/ReplayDialog-CBcnPgx1.js"
86
79
  },
87
- "/assets/zhipuai-BPNAnxo-.svg": {
88
- "type": "image/svg+xml",
89
- "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
90
- "mtime": "2026-06-30T03:21:19.568Z",
91
- "size": 11256,
92
- "path": "../public/assets/zhipuai-BPNAnxo-.svg"
80
+ "/assets/RequestAnatomy-DSgSqCYt.js": {
81
+ "type": "text/javascript; charset=utf-8",
82
+ "etag": '"4485-rBai6wnmYHf662t3omQFALcwHSw"',
83
+ "mtime": "2026-06-30T09:01:48.557Z",
84
+ "size": 17541,
85
+ "path": "../public/assets/RequestAnatomy-DSgSqCYt.js"
93
86
  },
94
- "/assets/main-Brj0Gn91.js": {
87
+ "/assets/ResponseView-CjqzBSoF.js": {
95
88
  "type": "text/javascript; charset=utf-8",
96
- "etag": '"51726-mwq2VuKeQG//deWEjQSFZIL9ZWo"',
97
- "mtime": "2026-06-30T03:21:19.568Z",
98
- "size": 333606,
99
- "path": "../public/assets/main-Brj0Gn91.js"
89
+ "etag": '"30d0-13nPwe9jIZSSl43vTEhXlswXwn8"',
90
+ "mtime": "2026-06-30T09:01:48.557Z",
91
+ "size": 12496,
92
+ "path": "../public/assets/ResponseView-CjqzBSoF.js"
100
93
  },
101
- "/assets/RequestAnatomy-Coyy3zcH.js": {
94
+ "/assets/StreamingChunkSequence-DdBBKolI.js": {
102
95
  "type": "text/javascript; charset=utf-8",
103
- "etag": '"4485-KFNCPYVKcx+a4jW1uo7q7Az4T5c"',
104
- "mtime": "2026-06-30T03:21:19.570Z",
105
- "size": 17541,
106
- "path": "../public/assets/RequestAnatomy-Coyy3zcH.js"
96
+ "etag": '"d57-9H1QMwesXGWHkYNTPJ/9zDoslX0"',
97
+ "mtime": "2026-06-30T09:01:48.557Z",
98
+ "size": 3415,
99
+ "path": "../public/assets/StreamingChunkSequence-DdBBKolI.js"
107
100
  },
108
- "/assets/_sessionId-9rEF0uSE.js": {
101
+ "/assets/_sessionId-wctOoGh_.js": {
109
102
  "type": "text/javascript; charset=utf-8",
110
- "etag": '"d2-+KgPaqhF/ImO1Zb9tZi0sMB8OPI"',
111
- "mtime": "2026-06-30T03:21:19.568Z",
103
+ "etag": '"d2-fiIvvYyX9jF4fd1W7HpowwQ2Zj4"',
104
+ "mtime": "2026-06-30T09:01:48.556Z",
112
105
  "size": 210,
113
- "path": "../public/assets/_sessionId-9rEF0uSE.js"
106
+ "path": "../public/assets/_sessionId-wctOoGh_.js"
114
107
  },
115
- "/assets/ResponseView-BWJzgh0c.js": {
108
+ "/assets/main-B6OLZCp9.js": {
116
109
  "type": "text/javascript; charset=utf-8",
117
- "etag": '"30d0-XQuX3LcRTXyoZYFeRDp9J2gMpd0"',
118
- "mtime": "2026-06-30T03:21:19.570Z",
119
- "size": 12496,
120
- "path": "../public/assets/ResponseView-BWJzgh0c.js"
110
+ "etag": '"51726-MaBaigWv6OLiiYg6fusFyN758Hw"',
111
+ "mtime": "2026-06-30T09:01:48.557Z",
112
+ "size": 333606,
113
+ "path": "../public/assets/main-B6OLZCp9.js"
121
114
  },
122
- "/assets/StreamingChunkSequence-BSC3uuE5.js": {
123
- "type": "text/javascript; charset=utf-8",
124
- "etag": '"d57-1GnBSzcha+n1obLlcv4lwyBhPf4"',
125
- "mtime": "2026-06-30T03:21:19.570Z",
126
- "size": 3415,
127
- "path": "../public/assets/StreamingChunkSequence-BSC3uuE5.js"
115
+ "/assets/zhipuai-BPNAnxo-.svg": {
116
+ "type": "image/svg+xml",
117
+ "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
118
+ "mtime": "2026-06-30T09:01:48.556Z",
119
+ "size": 11256,
120
+ "path": "../public/assets/zhipuai-BPNAnxo-.svg"
121
+ },
122
+ "/assets/qwen-CONDcHqt.png": {
123
+ "type": "image/png",
124
+ "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
125
+ "mtime": "2026-06-30T09:01:48.556Z",
126
+ "size": 357059,
127
+ "path": "../public/assets/qwen-CONDcHqt.png"
128
128
  },
129
- "/assets/ProxyViewerContainer-BccuA6p5.js": {
129
+ "/assets/ProxyViewerContainer-D650kQlv.js": {
130
130
  "type": "text/javascript; charset=utf-8",
131
- "etag": '"af111-eqyso0QJHFS7+IdUoP/1PcawtQE"',
132
- "mtime": "2026-06-30T03:21:19.570Z",
133
- "size": 717073,
134
- "path": "../public/assets/ProxyViewerContainer-BccuA6p5.js"
131
+ "etag": '"af8bd-kLHfI4KjZ80Xpnq5/VoFdzyBH10"',
132
+ "mtime": "2026-06-30T09:01:48.557Z",
133
+ "size": 719037,
134
+ "path": "../public/assets/ProxyViewerContainer-D650kQlv.js"
135
135
  }
136
136
  };
137
137
  function readAsset(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonyclaw/agent-inspector",
3
- "version": "2.0.28",
3
+ "version": "2.0.29",
4
4
  "type": "module",
5
5
  "description": "Agent observability and knowledge capture layer for AI coding tools.",
6
6
  "license": "MIT",
@@ -783,6 +783,9 @@ export function ProxyViewer({
783
783
  <Plus className="size-4 shrink-0 text-muted-foreground/70" aria-hidden="true" />
784
784
  <McpLogo className="size-10 shrink-0" />
785
785
  </span>
786
+ <span className="max-w-[calc(100vw-7rem)] text-sm font-semibold text-muted-foreground">
787
+ Local First. Evidence First.
788
+ </span>
786
789
  <McpReadyBadge />
787
790
  </h1>
788
791
  <div className="justify-self-end">
@@ -87,6 +87,16 @@ function hasHeaderDetails(headers: Record<string, string> | undefined): boolean
87
87
  return formatHeaders(headers) !== undefined;
88
88
  }
89
89
 
90
+ function formatElapsed(ms: number): string {
91
+ if (ms < 1000) return `${ms}ms`;
92
+ return `${(ms / 1000).toFixed(1)}s`;
93
+ }
94
+
95
+ function formatTokenRate(tokensPerSecond: number): string {
96
+ if (tokensPerSecond >= 10) return `${tokensPerSecond.toFixed(0)}/s`;
97
+ return `${tokensPerSecond.toFixed(1)}/s`;
98
+ }
99
+
90
100
  function hasDiagnosticDetails(result: TestResult): boolean {
91
101
  const debug = result.debug;
92
102
  return (
@@ -255,6 +265,7 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
255
265
  }
256
266
  if (result.success) {
257
267
  const tokenParts: ReactNode[] = [];
268
+ const timingParts: ReactNode[] = [];
258
269
  if (result.inputTokens !== undefined) {
259
270
  tokenParts.push(<span key="in">{result.inputTokens} in</span>);
260
271
  }
@@ -275,11 +286,29 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
275
286
  </span>,
276
287
  );
277
288
  }
289
+ if (result.firstChunkMs !== undefined && result.firstChunkMs !== null) {
290
+ timingParts.push(<span key="first">first {formatElapsed(result.firstChunkMs)}</span>);
291
+ }
292
+ if (result.totalStreamMs !== undefined && result.totalStreamMs !== null) {
293
+ timingParts.push(<span key="total">total {formatElapsed(result.totalStreamMs)}</span>);
294
+ }
295
+ if (
296
+ result.tokensPerSecond !== undefined &&
297
+ result.tokensPerSecond !== null &&
298
+ result.tokensPerSecond > 0
299
+ ) {
300
+ timingParts.push(<span key="rate">{formatTokenRate(result.tokensPerSecond)} tok/s</span>);
301
+ }
278
302
  const displayTokens: ReactNode[] = [];
279
303
  for (let i = 0; i < tokenParts.length; i++) {
280
304
  if (i > 0) displayTokens.push(", ");
281
305
  displayTokens.push(tokenParts[i]);
282
306
  }
307
+ const displayTiming: ReactNode[] = [];
308
+ for (let i = 0; i < timingParts.length; i++) {
309
+ if (i > 0) displayTiming.push(", ");
310
+ displayTiming.push(timingParts[i]);
311
+ }
283
312
  return (
284
313
  <TooltipProvider>
285
314
  <Tooltip>
@@ -290,6 +319,9 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
290
319
  {tokenParts.length > 0 && (
291
320
  <span className="text-muted-foreground">({displayTokens})</span>
292
321
  )}
322
+ {timingParts.length > 0 && (
323
+ <span className="hidden text-muted-foreground lg:inline">({displayTiming})</span>
324
+ )}
293
325
  </div>
294
326
  </TooltipTrigger>
295
327
  <TooltipContent>Connection test passed</TooltipContent>
@@ -11,6 +11,7 @@ import {
11
11
  Copy,
12
12
  FileDiff,
13
13
  FileTerminal,
14
+ Gauge,
14
15
  Globe,
15
16
  History,
16
17
  Loader2,
@@ -45,6 +46,11 @@ function formatElapsed(ms: number): string {
45
46
  return `${(ms / 1000).toFixed(1)}s`;
46
47
  }
47
48
 
49
+ function formatTokenRate(tokensPerSecond: number): string {
50
+ if (tokensPerSecond >= 10) return `${tokensPerSecond.toFixed(0)}/s`;
51
+ return `${tokensPerSecond.toFixed(1)}/s`;
52
+ }
53
+
48
54
  /**
49
55
  * Inline trend indicator: small arrow (green up / red down) plus the absolute
50
56
  * delta in compact form. Returns `null` when there is no trend to display.
@@ -148,6 +154,11 @@ export const LogEntryHeader = memo(function ({
148
154
  log.elapsedMs > slowResponseThresholdSeconds * 1000;
149
155
 
150
156
  const hasTokens = log.inputTokens !== null || log.outputTokens !== null;
157
+ const firstChunkMs = log.firstChunkMs ?? null;
158
+ const tokensPerSecond = log.tokensPerSecond ?? null;
159
+ const firstChunkLabel = firstChunkMs === null ? null : formatElapsed(firstChunkMs);
160
+ const tokenRateLabel =
161
+ tokensPerSecond === null || tokensPerSecond <= 0 ? null : formatTokenRate(tokensPerSecond);
151
162
 
152
163
  return (
153
164
  <TooltipProvider>
@@ -238,11 +249,37 @@ export const LogEntryHeader = memo(function ({
238
249
  ? `Slow response: ${formatElapsed(log.elapsedMs)} exceeds ${formatElapsed(
239
250
  slowResponseThresholdSeconds * 1000,
240
251
  )}`
241
- : "Elapsed response time"}
252
+ : log.streaming
253
+ ? "Total streaming response time"
254
+ : "Elapsed response time"}
242
255
  </TooltipContent>
243
256
  </Tooltip>
244
257
  )}
245
258
 
259
+ {/* Streaming timing */}
260
+ {log.streaming && firstChunkLabel !== null && (
261
+ <Tooltip>
262
+ <TooltipTrigger asChild>
263
+ <span className="flex items-center gap-1 text-xs text-cyan-400 shrink-0">
264
+ <Radio className="size-3" />
265
+ <span className="font-mono tabular-nums">first {firstChunkLabel}</span>
266
+ </span>
267
+ </TooltipTrigger>
268
+ <TooltipContent>Time to first streaming chunk</TooltipContent>
269
+ </Tooltip>
270
+ )}
271
+ {log.streaming && tokenRateLabel !== null && (
272
+ <Tooltip>
273
+ <TooltipTrigger asChild>
274
+ <span className="flex items-center gap-1 text-xs text-emerald-400 shrink-0">
275
+ <Gauge className="size-3" />
276
+ <span className="font-mono tabular-nums">{tokenRateLabel}</span>
277
+ </span>
278
+ </TooltipTrigger>
279
+ <TooltipContent>Output token throughput after first chunk</TooltipContent>
280
+ </Tooltip>
281
+ )}
282
+
246
283
  {/* Token counts */}
247
284
  {hasTokens && (
248
285
  <span className="flex items-center gap-1 text-xs shrink-0">
@@ -45,6 +45,9 @@ export const ProviderTestResultSchema = z.object({
45
45
  cacheCreationInputTokens: z.number().optional(),
46
46
  cacheReadInputTokens: z.number().optional(),
47
47
  latencyMs: z.number().optional(),
48
+ firstChunkMs: z.number().nullable().optional(),
49
+ totalStreamMs: z.number().nullable().optional(),
50
+ tokensPerSecond: z.number().nullable().optional(),
48
51
  content: z.array(ProviderTestContentSchema).optional(),
49
52
  rawResponse: z.string().optional(),
50
53
  streaming: z.boolean().optional(),
@@ -21,6 +21,9 @@ export const SessionLogSummarySchema = z.object({
21
21
  hasError: z.boolean(),
22
22
  error: z.string().nullable(),
23
23
  latencyMs: z.number().nullable(),
24
+ firstChunkMs: z.number().nullable().optional(),
25
+ totalStreamMs: z.number().nullable().optional(),
26
+ tokensPerSecond: z.number().nullable().optional(),
24
27
  tokens: z.object({
25
28
  input: z.number().nullable(),
26
29
  output: z.number().nullable(),
@@ -132,6 +132,9 @@ export function buildLogSummary(log: CapturedLog) {
132
132
  isStreaming: log.streaming,
133
133
  hasError,
134
134
  latencyMs: log.elapsedMs,
135
+ firstChunkMs: log.firstChunkMs ?? null,
136
+ totalStreamMs: log.totalStreamMs ?? null,
137
+ tokensPerSecond: log.tokensPerSecond ?? null,
135
138
  tokens: {
136
139
  input: log.inputTokens,
137
140
  output: log.outputTokens,
@@ -318,6 +318,11 @@ function logDetails(log: SessionLogSummary): string {
318
318
  `streaming=${log.isStreaming ? "yes" : "no"}`,
319
319
  `latencyMs=${formatNullable(log.latencyMs)}`,
320
320
  ];
321
+ if (log.isStreaming) {
322
+ parts.push(`firstChunkMs=${formatNullable(log.firstChunkMs ?? null)}`);
323
+ parts.push(`totalStreamMs=${formatNullable(log.totalStreamMs ?? null)}`);
324
+ parts.push(`tokensPerSecond=${formatNullable(log.tokensPerSecond ?? null)}`);
325
+ }
321
326
  if (log.error !== null) {
322
327
  parts.push(`error=${log.error}`);
323
328
  }
@@ -112,14 +112,19 @@ function buildMarkdown(document: EvidenceDocument): string {
112
112
  lines.push("No compact logs were available.", "");
113
113
  } else {
114
114
  lines.push(
115
- "| ID | Status | Model | Latency | Path | Error |",
116
- "| --- | --- | --- | --- | --- | --- |",
115
+ "| ID | Status | Model | Latency | Stream | Path | Error |",
116
+ "| --- | --- | --- | --- | --- | --- | --- |",
117
117
  );
118
118
  for (const log of session.latestLogs) {
119
+ const streamTiming = log.isStreaming
120
+ ? `first=${log.firstChunkMs ?? "n/a"}ms total=${log.totalStreamMs ?? "n/a"}ms tps=${
121
+ log.tokensPerSecond ?? "n/a"
122
+ }`
123
+ : "n/a";
119
124
  lines.push(
120
125
  `| ${log.id} | ${log.status ?? "n/a"} | ${log.model ?? "n/a"} | ${
121
126
  log.latencyMs ?? "n/a"
122
- } | ${log.path} | ${log.hasError ? "yes" : "no"} |`,
127
+ } | ${streamTiming} | ${log.path} | ${log.hasError ? "yes" : "no"} |`,
123
128
  );
124
129
  }
125
130
  lines.push("");
@@ -142,12 +142,14 @@ function handleStreamingResponse(
142
142
  const capture = new RawStreamCapture();
143
143
  const decoder = new TextDecoder();
144
144
  let finalizationScheduled = false;
145
+ let firstChunkMs: number | null = null;
145
146
 
146
147
  const scheduleStreamingFinalization = (type: "streaming" | "stream-abort"): void => {
147
148
  if (finalizationScheduled) return;
148
149
  finalizationScheduled = true;
149
150
 
150
- const elapsedMs = Date.now() - startTime;
151
+ const totalStreamMs = Date.now() - startTime;
152
+ const elapsedMs = totalStreamMs;
151
153
  const rawStream = capture.snapshot();
152
154
  switch (type) {
153
155
  case "streaming":
@@ -159,6 +161,8 @@ function handleStreamingResponse(
159
161
  responseStatus: upstreamRes.status,
160
162
  rawStream,
161
163
  collectStreamingChunks,
164
+ firstChunkMs,
165
+ totalStreamMs,
162
166
  });
163
167
  return;
164
168
  case "stream-abort":
@@ -170,6 +174,8 @@ function handleStreamingResponse(
170
174
  rawStream,
171
175
  hasChunks: capture.hasChunks,
172
176
  collectStreamingChunks,
177
+ firstChunkMs,
178
+ totalStreamMs,
173
179
  });
174
180
  return;
175
181
  }
@@ -177,6 +183,7 @@ function handleStreamingResponse(
177
183
 
178
184
  const transform = new TransformStream<Uint8Array, Uint8Array>({
179
185
  async transform(chunk, controller) {
186
+ if (firstChunkMs === null) firstChunkMs = Date.now() - startTime;
180
187
  controller.enqueue(chunk);
181
188
  await capture.append(decoder.decode(chunk, { stream: true }));
182
189
  },
@@ -8,6 +8,7 @@ import type { RawStreamSource } from "./rawStreamCapture";
8
8
  import type { CapturedLog } from "./schemas";
9
9
  import { getSessionProcess, isSessionProcessAvailable } from "./sessionProcess";
10
10
  import { finalizeLogUpdate } from "./store";
11
+ import { computeTokensPerSecond } from "./streamTiming";
11
12
 
12
13
  type BaseFinalizeLogJob = {
13
14
  log: CapturedLog;
@@ -26,6 +27,8 @@ export type FinalizeStreamingLogJob = BaseFinalizeLogJob & {
26
27
  responseStatus: number;
27
28
  rawStream: string | RawStreamSource;
28
29
  collectStreamingChunks: boolean;
30
+ firstChunkMs: number | null;
31
+ totalStreamMs: number;
29
32
  };
30
33
 
31
34
  export type FinalizeStreamAbortLogJob = BaseFinalizeLogJob & {
@@ -33,6 +36,8 @@ export type FinalizeStreamAbortLogJob = BaseFinalizeLogJob & {
33
36
  rawStream: string | RawStreamSource;
34
37
  hasChunks: boolean;
35
38
  collectStreamingChunks: boolean;
39
+ firstChunkMs: number | null;
40
+ totalStreamMs: number;
36
41
  };
37
42
 
38
43
  export type FinalizeLogJob =
@@ -73,6 +78,9 @@ export function buildFileLogEntry(log: CapturedLog, upstreamUrl: string): Record
73
78
  cacheCreationInputTokens: log.cacheCreationInputTokens,
74
79
  cacheReadInputTokens: log.cacheReadInputTokens,
75
80
  elapsedMs: log.elapsedMs,
81
+ firstChunkMs: log.firstChunkMs,
82
+ totalStreamMs: log.totalStreamMs,
83
+ tokensPerSecond: log.tokensPerSecond,
76
84
  streaming: log.streaming,
77
85
  userAgent: log.userAgent,
78
86
  origin: log.origin,
@@ -119,10 +127,31 @@ function finalizeWithError(
119
127
  log.responseStatus = log.responseStatus ?? fallbackStatus;
120
128
  log.responseText = log.responseText ?? fallbackResponseText;
121
129
  log.elapsedMs = job.elapsedMs;
130
+ applyOptionalStreamingTiming(log, job);
122
131
  log.error = message;
123
132
  return { log, upstreamUrl: job.upstreamUrl, error: message, cleanupRawStreamPath };
124
133
  }
125
134
 
135
+ function applyOptionalStreamingTiming(log: CapturedLog, job: FinalizeLogJob): void {
136
+ switch (job.type) {
137
+ case "non-streaming":
138
+ log.firstChunkMs = null;
139
+ log.totalStreamMs = null;
140
+ log.tokensPerSecond = null;
141
+ return;
142
+ case "streaming":
143
+ case "stream-abort":
144
+ log.firstChunkMs = job.firstChunkMs;
145
+ log.totalStreamMs = job.totalStreamMs;
146
+ log.tokensPerSecond = computeTokensPerSecond({
147
+ outputTokens: log.outputTokens,
148
+ firstChunkMs: job.firstChunkMs,
149
+ totalStreamMs: job.totalStreamMs,
150
+ });
151
+ return;
152
+ }
153
+ }
154
+
126
155
  function finalizeNonStreaming(
127
156
  job: FinalizeNonStreamingLogJob,
128
157
  log: CapturedLog,
@@ -136,6 +165,9 @@ function finalizeNonStreaming(
136
165
  const tokens = formatHandler.extractTokens(job.responseBody);
137
166
 
138
167
  log.elapsedMs = job.elapsedMs;
168
+ log.firstChunkMs = null;
169
+ log.totalStreamMs = null;
170
+ log.tokensPerSecond = null;
139
171
  log.responseStatus = job.responseStatus;
140
172
  log.responseText = job.responseBody;
141
173
  log.inputTokens = tokens.inputTokens;
@@ -238,6 +270,7 @@ function finalizeStreaming(job: FinalizeStreamingLogJob, log: CapturedLog): Fina
238
270
  const oversized = getOversizedRawStream(job.rawStream);
239
271
  if (oversized !== null) {
240
272
  log.elapsedMs = job.elapsedMs;
273
+ applyOptionalStreamingTiming(log, job);
241
274
  log.responseStatus = job.responseStatus;
242
275
  log.responseText = oversizedRawStreamText(oversized);
243
276
  return {
@@ -262,6 +295,7 @@ function finalizeStreaming(job: FinalizeStreamingLogJob, log: CapturedLog): Fina
262
295
  log.model ?? undefined,
263
296
  job.collectStreamingChunks,
264
297
  );
298
+ applyOptionalStreamingTiming(log, job);
265
299
  persistStreamingChunks(log);
266
300
 
267
301
  return { log, upstreamUrl: job.upstreamUrl, error: null, cleanupRawStreamPath };
@@ -294,6 +328,7 @@ function finalizeStreamAbort(job: FinalizeStreamAbortLogJob, log: CapturedLog):
294
328
  let cleanupRawStreamPath: string | null = null;
295
329
  try {
296
330
  log.elapsedMs = job.elapsedMs;
331
+ applyOptionalStreamingTiming(log, job);
297
332
  if (job.hasChunks && formatHandler !== null) {
298
333
  const oversized = getOversizedRawStream(job.rawStream);
299
334
  if (oversized !== null) {
@@ -314,6 +349,7 @@ function finalizeStreamAbort(job: FinalizeStreamAbortLogJob, log: CapturedLog):
314
349
  log.model ?? undefined,
315
350
  job.collectStreamingChunks,
316
351
  );
352
+ applyOptionalStreamingTiming(log, job);
317
353
  persistStreamingChunks(log);
318
354
  } else {
319
355
  log.responseText = "Client aborted";
@@ -40,6 +40,9 @@ export const CapturedLogSchema = z.object({
40
40
  cacheCreationInputTokens: z.number().nullable(),
41
41
  cacheReadInputTokens: z.number().nullable(),
42
42
  elapsedMs: z.number().nullable(),
43
+ firstChunkMs: z.number().nullable().optional(),
44
+ totalStreamMs: z.number().nullable().optional(),
45
+ tokensPerSecond: z.number().nullable().optional(),
43
46
  streaming: z.boolean(),
44
47
  userAgent: z.string().nullable(),
45
48
  origin: z.string().nullable(),
@@ -55,6 +55,9 @@ export function buildSessionLogSummary(log: CapturedLog): SessionLogSummary {
55
55
  hasError: hasLogError(log),
56
56
  error: truncateError(log.error),
57
57
  latencyMs: log.elapsedMs,
58
+ firstChunkMs: log.firstChunkMs ?? null,
59
+ totalStreamMs: log.totalStreamMs ?? null,
60
+ tokensPerSecond: log.tokensPerSecond ?? null,
58
61
  tokens: {
59
62
  input: log.inputTokens,
60
63
  output: log.outputTokens,
@@ -233,6 +233,9 @@ export async function createLog(
233
233
  cacheCreationInputTokens: null,
234
234
  cacheReadInputTokens: null,
235
235
  elapsedMs: null,
236
+ firstChunkMs: null,
237
+ totalStreamMs: null,
238
+ tokensPerSecond: null,
236
239
  streaming: false,
237
240
  userAgent,
238
241
  origin,
@@ -0,0 +1,31 @@
1
+ import type { StreamingChunk } from "./schemas";
2
+
3
+ export function computeTokensPerSecond({
4
+ outputTokens,
5
+ firstChunkMs,
6
+ totalStreamMs,
7
+ }: {
8
+ outputTokens: number | null | undefined;
9
+ firstChunkMs: number | null | undefined;
10
+ totalStreamMs: number | null | undefined;
11
+ }): number | null {
12
+ if (outputTokens === null || outputTokens === undefined || outputTokens <= 0) return null;
13
+ if (totalStreamMs === null || totalStreamMs === undefined || totalStreamMs <= 0) return null;
14
+
15
+ const generationMs =
16
+ firstChunkMs === null || firstChunkMs === undefined
17
+ ? totalStreamMs
18
+ : totalStreamMs - firstChunkMs;
19
+ if (!Number.isFinite(generationMs) || generationMs <= 0) return null;
20
+
21
+ return Number((outputTokens / (generationMs / 1000)).toFixed(2));
22
+ }
23
+
24
+ export function firstChunkMsFromChunks(
25
+ chunks: readonly StreamingChunk[] | undefined,
26
+ ): number | null {
27
+ const first = chunks?.[0];
28
+ if (first === undefined) return null;
29
+ if (!Number.isFinite(first.timestamp) || first.timestamp < 0) return null;
30
+ return Math.floor(first.timestamp);
31
+ }