@tonyclaw/agent-inspector 2.0.28 → 2.0.30

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 (54) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-CicCP3Hb.js → CompareDrawer-BDzfRSt1.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-NtOD8Ex_.js +115 -0
  4. package/.output/public/assets/{ReplayDialog-Bc8q3ujm.js → ReplayDialog-p-5TKg75.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-Coyy3zcH.js → RequestAnatomy-G0dV8-D_.js} +1 -1
  6. package/.output/public/assets/{ResponseView-BWJzgh0c.js → ResponseView-D2wE6lD-.js} +1 -1
  7. package/.output/public/assets/{StreamingChunkSequence-BSC3uuE5.js → StreamingChunkSequence-D8JRK-j_.js} +1 -1
  8. package/.output/public/assets/_sessionId-zimzVrJE.js +1 -0
  9. package/.output/public/assets/index-Cv5vPgpr.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-XHHZsOBK.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +13 -13
  13. package/.output/server/{_sessionId-DDdyKVG-.mjs → _sessionId-CK9QEhkB.mjs} +3 -3
  14. package/.output/server/_ssr/{CompareDrawer-XSExe2ml.mjs → CompareDrawer-BJVPFMIx.mjs} +2 -2
  15. package/.output/server/_ssr/{ProxyViewerContainer-B5pIiBZN.mjs → ProxyViewerContainer-DhbmJ2zj.mjs} +83 -20
  16. package/.output/server/_ssr/{ReplayDialog-BOAu0ric.mjs → ReplayDialog-DXRj95Gj.mjs} +4 -4
  17. package/.output/server/_ssr/{RequestAnatomy-C8Q5lozD.mjs → RequestAnatomy-CrYJTXdJ.mjs} +2 -2
  18. package/.output/server/_ssr/{ResponseView-a_iJfPUF.mjs → ResponseView-4ESqSITr.mjs} +2 -2
  19. package/.output/server/_ssr/{StreamingChunkSequence-YMKC0j8U.mjs → StreamingChunkSequence-Czdko5n_.mjs} +2 -2
  20. package/.output/server/_ssr/{index-CkDwBPzI.mjs → index-bei0I7qL.mjs} +2 -2
  21. package/.output/server/_ssr/index.mjs +2 -2
  22. package/.output/server/_ssr/{router-DXOXdFFM.mjs → router-sQh4Gk8J.mjs} +1338 -137
  23. package/.output/server/_tanstack-start-manifest_v-C0fdSWnA.mjs +4 -0
  24. package/.output/server/index.mjs +64 -64
  25. package/README.md +24 -1
  26. package/package.json +1 -1
  27. package/src/components/ProxyViewer.tsx +3 -0
  28. package/src/components/providers/ProviderCard.tsx +32 -0
  29. package/src/components/proxy-viewer/LogEntryHeader.tsx +38 -1
  30. package/src/lib/groupContract.ts +139 -0
  31. package/src/lib/providerTestContract.ts +3 -0
  32. package/src/lib/sessionInfoContract.ts +3 -0
  33. package/src/mcp/server.ts +278 -0
  34. package/src/mcp/toolHandlers.ts +126 -0
  35. package/src/proxy/evidenceAnalysis.ts +5 -0
  36. package/src/proxy/evidenceExporter.ts +8 -3
  37. package/src/proxy/groupEvidenceExporter.ts +341 -0
  38. package/src/proxy/groupStore.ts +259 -0
  39. package/src/proxy/handler.ts +8 -1
  40. package/src/proxy/logFinalizer.ts +36 -0
  41. package/src/proxy/schemas.ts +3 -0
  42. package/src/proxy/sessionInfo.ts +3 -0
  43. package/src/proxy/store.ts +3 -0
  44. package/src/proxy/streamTiming.ts +31 -0
  45. package/src/routes/api/groups.$groupId.evidence.ts +62 -0
  46. package/src/routes/api/groups.$groupId.sessions.ts +50 -0
  47. package/src/routes/api/groups.$groupId.ts +50 -0
  48. package/src/routes/api/groups.ts +39 -0
  49. package/src/routes/api/logs.$id.replay.ts +25 -2
  50. package/src/routes/api/providers.$providerId.test.log.ts +34 -2
  51. package/.output/public/assets/ProxyViewerContainer-BccuA6p5.js +0 -115
  52. package/.output/public/assets/_sessionId-9rEF0uSE.js +0 -1
  53. package/.output/public/assets/index-B9_VaAWl.js +0 -1
  54. package/.output/server/_tanstack-start-manifest_v-DPf3uJys.mjs +0 -4
@@ -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
+ }
@@ -0,0 +1,62 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { GroupEvidenceExportOptionsSchema } from "../../lib/groupContract";
3
+ import { exportGroupEvidence, readGroupEvidenceMarkdown } from "../../proxy/groupEvidenceExporter";
4
+ import { getGroup } from "../../proxy/groupStore";
5
+
6
+ type JsonBodyResult = { ok: true; value: unknown } | { ok: false };
7
+
8
+ async function readJsonBody(request: Request): Promise<JsonBodyResult> {
9
+ try {
10
+ const raw = await request.text();
11
+ if (raw.trim().length === 0) return { ok: true, value: undefined };
12
+ return { ok: true, value: JSON.parse(raw) };
13
+ } catch {
14
+ return { ok: false };
15
+ }
16
+ }
17
+
18
+ export const Route = createFileRoute("/api/groups/$groupId/evidence")({
19
+ server: {
20
+ handlers: {
21
+ GET: ({ params }: { params: { groupId: string } }) => {
22
+ const group = getGroup(params.groupId);
23
+ if (group === null) {
24
+ return Response.json({ error: "Group not found" }, { status: 404 });
25
+ }
26
+
27
+ const markdown = readGroupEvidenceMarkdown(group);
28
+ if (group.evidence === null || markdown === null) {
29
+ return Response.json(
30
+ { error: "Evidence has not been exported for this group" },
31
+ { status: 404 },
32
+ );
33
+ }
34
+
35
+ return Response.json({ group, evidence: group.evidence, markdown });
36
+ },
37
+ POST: async ({ params, request }: { params: { groupId: string }; request: Request }) => {
38
+ const group = getGroup(params.groupId);
39
+ if (group === null) {
40
+ return Response.json({ error: "Group not found" }, { status: 404 });
41
+ }
42
+
43
+ const body = await readJsonBody(request);
44
+ if (!body.ok) {
45
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
46
+ }
47
+
48
+ const parsed = GroupEvidenceExportOptionsSchema.safeParse(body.value);
49
+ if (!parsed.success) {
50
+ return Response.json(
51
+ { error: "Invalid request body", details: parsed.error.format() },
52
+ { status: 400 },
53
+ );
54
+ }
55
+
56
+ return Response.json(
57
+ await exportGroupEvidence(group, parsed.data, new URL(request.url).origin),
58
+ );
59
+ },
60
+ },
61
+ },
62
+ });
@@ -0,0 +1,50 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { AddInspectorGroupSessionInputSchema } from "../../lib/groupContract";
3
+ import { addGroupSession, getGroup } from "../../proxy/groupStore";
4
+
5
+ type JsonBodyResult = { ok: true; value: unknown } | { ok: false };
6
+
7
+ async function readJsonBody(request: Request): Promise<JsonBodyResult> {
8
+ try {
9
+ const raw = await request.text();
10
+ if (raw.trim().length === 0) return { ok: true, value: {} };
11
+ return { ok: true, value: JSON.parse(raw) };
12
+ } catch {
13
+ return { ok: false };
14
+ }
15
+ }
16
+
17
+ export const Route = createFileRoute("/api/groups/$groupId/sessions")({
18
+ server: {
19
+ handlers: {
20
+ GET: ({ params }: { params: { groupId: string } }) => {
21
+ const group = getGroup(params.groupId);
22
+ if (group === null) {
23
+ return Response.json({ error: "Group not found" }, { status: 404 });
24
+ }
25
+ return Response.json({ groupId: group.id, members: group.members });
26
+ },
27
+ POST: async ({ params, request }: { params: { groupId: string }; request: Request }) => {
28
+ const body = await readJsonBody(request);
29
+ if (!body.ok) {
30
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
31
+ }
32
+
33
+ const parsed = AddInspectorGroupSessionInputSchema.safeParse(body.value);
34
+ if (!parsed.success) {
35
+ return Response.json(
36
+ { error: "Invalid request body", details: parsed.error.format() },
37
+ { status: 400 },
38
+ );
39
+ }
40
+
41
+ const updated = addGroupSession(params.groupId, parsed.data);
42
+ if (updated === null) {
43
+ return Response.json({ error: "Group not found" }, { status: 404 });
44
+ }
45
+
46
+ return Response.json(updated);
47
+ },
48
+ },
49
+ },
50
+ });
@@ -0,0 +1,50 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { UpdateInspectorGroupInputSchema } from "../../lib/groupContract";
3
+ import { getGroup, updateGroup } from "../../proxy/groupStore";
4
+
5
+ type JsonBodyResult = { ok: true; value: unknown } | { ok: false };
6
+
7
+ async function readJsonBody(request: Request): Promise<JsonBodyResult> {
8
+ try {
9
+ const raw = await request.text();
10
+ if (raw.trim().length === 0) return { ok: true, value: {} };
11
+ return { ok: true, value: JSON.parse(raw) };
12
+ } catch {
13
+ return { ok: false };
14
+ }
15
+ }
16
+
17
+ export const Route = createFileRoute("/api/groups/$groupId")({
18
+ server: {
19
+ handlers: {
20
+ GET: ({ params }: { params: { groupId: string } }) => {
21
+ const group = getGroup(params.groupId);
22
+ if (group === null) {
23
+ return Response.json({ error: "Group not found" }, { status: 404 });
24
+ }
25
+ return Response.json(group);
26
+ },
27
+ PATCH: async ({ params, request }: { params: { groupId: string }; request: Request }) => {
28
+ const body = await readJsonBody(request);
29
+ if (!body.ok) {
30
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
31
+ }
32
+
33
+ const parsed = UpdateInspectorGroupInputSchema.safeParse(body.value);
34
+ if (!parsed.success) {
35
+ return Response.json(
36
+ { error: "Invalid request body", details: parsed.error.format() },
37
+ { status: 400 },
38
+ );
39
+ }
40
+
41
+ const updated = updateGroup(params.groupId, parsed.data);
42
+ if (updated === null) {
43
+ return Response.json({ error: "Group not found" }, { status: 404 });
44
+ }
45
+
46
+ return Response.json(updated);
47
+ },
48
+ },
49
+ },
50
+ });
@@ -0,0 +1,39 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { CreateInspectorGroupInputSchema } from "../../lib/groupContract";
3
+ import { createGroup, listGroups } from "../../proxy/groupStore";
4
+
5
+ type JsonBodyResult = { ok: true; value: unknown } | { ok: false };
6
+
7
+ async function readJsonBody(request: Request): Promise<JsonBodyResult> {
8
+ try {
9
+ const raw = await request.text();
10
+ if (raw.trim().length === 0) return { ok: true, value: undefined };
11
+ return { ok: true, value: JSON.parse(raw) };
12
+ } catch {
13
+ return { ok: false };
14
+ }
15
+ }
16
+
17
+ export const Route = createFileRoute("/api/groups")({
18
+ server: {
19
+ handlers: {
20
+ GET: () => Response.json({ groups: listGroups() }),
21
+ POST: async ({ request }: { request: Request }) => {
22
+ const body = await readJsonBody(request);
23
+ if (!body.ok) {
24
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
25
+ }
26
+
27
+ const parsed = CreateInspectorGroupInputSchema.safeParse(body.value);
28
+ if (!parsed.success) {
29
+ return Response.json(
30
+ { error: "Invalid request body", details: parsed.error.format() },
31
+ { status: 400 },
32
+ );
33
+ }
34
+
35
+ return Response.json(createGroup(parsed.data), { status: 201 });
36
+ },
37
+ },
38
+ },
39
+ });
@@ -21,6 +21,7 @@ import {
21
21
  selectUpstreamBase,
22
22
  setUpstreamHost,
23
23
  } from "../../proxy/upstream";
24
+ import { computeTokensPerSecond } from "../../proxy/streamTiming";
24
25
 
25
26
  type ReplayRequest = {
26
27
  modifiedBody: string;
@@ -38,6 +39,9 @@ type ReplayResponse = {
38
39
  inputTokens?: number;
39
40
  outputTokens?: number;
40
41
  elapsedMs?: number;
42
+ firstChunkMs?: number | null;
43
+ totalStreamMs?: number | null;
44
+ tokensPerSecond?: number | null;
41
45
  streaming?: boolean;
42
46
  replayLogId?: number;
43
47
  };
@@ -135,7 +139,6 @@ export const Route = createFileRoute("/api/logs/$id/replay")({
135
139
  } satisfies ReplayResponse);
136
140
  }
137
141
 
138
- const elapsedMs = Date.now() - startTime;
139
142
  const isStream =
140
143
  upstreamRes.headers.get(HEADER_CONTENT_TYPE)?.includes(CONTENT_TYPE_EVENT_STREAM) ??
141
144
  false;
@@ -145,11 +148,13 @@ export const Route = createFileRoute("/api/logs/$id/replay")({
145
148
  const chunks: string[] = [];
146
149
  const decoder = new TextDecoder();
147
150
  const reader = upstreamRes.body?.getReader();
151
+ let firstChunkMs: number | null = null;
148
152
  if (reader) {
149
153
  try {
150
154
  while (true) {
151
155
  const { done, value } = await reader.read();
152
156
  if (done) break;
157
+ if (firstChunkMs === null) firstChunkMs = Date.now() - startTime;
153
158
  chunks.push(decoder.decode(value, { stream: true }));
154
159
  }
155
160
  } catch {
@@ -157,11 +162,19 @@ export const Route = createFileRoute("/api/logs/$id/replay")({
157
162
  }
158
163
  }
159
164
  const fullResponse = chunks.join("");
165
+ const elapsedMs = Date.now() - startTime;
166
+ const totalStreamMs = elapsedMs;
160
167
 
161
168
  // Create a mock log for streaming extraction
162
169
  const mockLog: typeof log = { ...log };
163
170
  const responseText = formatHandler.extractStream(fullResponse, mockLog, model, false);
164
171
  const tokens = formatHandler.extractTokens(responseText);
172
+ const outputTokens = tokens.outputTokens ?? null;
173
+ const tokensPerSecond = computeTokensPerSecond({
174
+ outputTokens,
175
+ firstChunkMs,
176
+ totalStreamMs,
177
+ });
165
178
  const savedReplayLog = await addTestLogEntry({
166
179
  timestamp: new Date().toISOString(),
167
180
  method: log.method,
@@ -172,10 +185,13 @@ export const Route = createFileRoute("/api/logs/$id/replay")({
172
185
  responseStatus: upstreamRes.status,
173
186
  responseText,
174
187
  inputTokens: tokens.inputTokens ?? null,
175
- outputTokens: tokens.outputTokens ?? null,
188
+ outputTokens,
176
189
  cacheCreationInputTokens: tokens.cacheCreationInputTokens ?? null,
177
190
  cacheReadInputTokens: tokens.cacheReadInputTokens ?? null,
178
191
  elapsedMs,
192
+ firstChunkMs,
193
+ totalStreamMs,
194
+ tokensPerSecond,
179
195
  streaming: true,
180
196
  userAgent: log.userAgent,
181
197
  origin: log.origin,
@@ -201,11 +217,15 @@ export const Route = createFileRoute("/api/logs/$id/replay")({
201
217
  inputTokens: tokens.inputTokens ?? undefined,
202
218
  outputTokens: tokens.outputTokens ?? undefined,
203
219
  elapsedMs,
220
+ firstChunkMs,
221
+ totalStreamMs,
222
+ tokensPerSecond,
204
223
  streaming: true,
205
224
  replayLogId: savedReplayLog.id,
206
225
  } satisfies ReplayResponse);
207
226
  } else {
208
227
  const responseText = await upstreamRes.text();
228
+ const elapsedMs = Date.now() - startTime;
209
229
  const tokens = formatHandler.extractTokens(responseText);
210
230
  const savedReplayLog = await addTestLogEntry({
211
231
  timestamp: new Date().toISOString(),
@@ -221,6 +241,9 @@ export const Route = createFileRoute("/api/logs/$id/replay")({
221
241
  cacheCreationInputTokens: tokens.cacheCreationInputTokens ?? null,
222
242
  cacheReadInputTokens: tokens.cacheReadInputTokens ?? null,
223
243
  elapsedMs,
244
+ firstChunkMs: null,
245
+ totalStreamMs: null,
246
+ tokensPerSecond: null,
224
247
  streaming: false,
225
248
  userAgent: log.userAgent,
226
249
  origin: log.origin,
@@ -8,6 +8,7 @@ import {
8
8
  type ProviderTestResult as TestResult,
9
9
  type ProviderTestState,
10
10
  } from "../../lib/providerTestContract";
11
+ import { computeTokensPerSecond, firstChunkMsFromChunks } from "../../proxy/streamTiming";
11
12
 
12
13
  function hasSuccessField(result: ProviderTestState): result is TestResult {
13
14
  return Object.prototype.hasOwnProperty.call(result, "success");
@@ -29,6 +30,25 @@ function getResultHeaders(result: TestResult): Record<string, string> {
29
30
  return result.requestHeaders ?? result.debug?.requestHeaders ?? {};
30
31
  }
31
32
 
33
+ function getResultTotalStreamMs(result: TestResult): number | null {
34
+ return result.totalStreamMs ?? (result.streaming === true ? (result.latencyMs ?? null) : null);
35
+ }
36
+
37
+ function getResultFirstChunkMs(result: TestResult): number | null {
38
+ return result.firstChunkMs ?? firstChunkMsFromChunks(result.streamingChunks?.chunks);
39
+ }
40
+
41
+ function getResultTokensPerSecond(result: TestResult): number | null {
42
+ return (
43
+ result.tokensPerSecond ??
44
+ computeTokensPerSecond({
45
+ outputTokens: result.outputTokens,
46
+ firstChunkMs: getResultFirstChunkMs(result),
47
+ totalStreamMs: getResultTotalStreamMs(result),
48
+ })
49
+ );
50
+ }
51
+
32
52
  async function logModelResults(
33
53
  displayName: string,
34
54
  providerName: string,
@@ -65,6 +85,9 @@ async function logModelResults(
65
85
  cacheCreationInputTokens: nonStreamingResult.cacheCreationInputTokens ?? null,
66
86
  cacheReadInputTokens: nonStreamingResult.cacheReadInputTokens ?? null,
67
87
  elapsedMs: nonStreamingResult.latencyMs ?? 0,
88
+ firstChunkMs: null,
89
+ totalStreamMs: null,
90
+ tokensPerSecond: null,
68
91
  streaming: false,
69
92
  userAgent: "provider-test",
70
93
  origin: null,
@@ -91,7 +114,10 @@ async function logModelResults(
91
114
  outputTokens: streamingResult.outputTokens ?? null,
92
115
  cacheCreationInputTokens: streamingResult.cacheCreationInputTokens ?? null,
93
116
  cacheReadInputTokens: streamingResult.cacheReadInputTokens ?? null,
94
- elapsedMs: streamingResult.latencyMs ?? 0,
117
+ elapsedMs: getResultTotalStreamMs(streamingResult) ?? 0,
118
+ firstChunkMs: getResultFirstChunkMs(streamingResult),
119
+ totalStreamMs: getResultTotalStreamMs(streamingResult),
120
+ tokensPerSecond: getResultTokensPerSecond(streamingResult),
95
121
  streaming: true,
96
122
  streamingChunks: captureFullDetails ? streamingResult.streamingChunks : undefined,
97
123
  userAgent: "provider-test",
@@ -127,6 +153,9 @@ async function logModelResults(
127
153
  cacheCreationInputTokens: null,
128
154
  cacheReadInputTokens: null,
129
155
  elapsedMs: nonStreamingResult.latencyMs ?? 0,
156
+ firstChunkMs: null,
157
+ totalStreamMs: null,
158
+ tokensPerSecond: null,
130
159
  streaming: false,
131
160
  userAgent: "provider-test",
132
161
  origin: null,
@@ -153,7 +182,10 @@ async function logModelResults(
153
182
  outputTokens: streamingResult.outputTokens ?? null,
154
183
  cacheCreationInputTokens: null,
155
184
  cacheReadInputTokens: null,
156
- elapsedMs: streamingResult.latencyMs ?? 0,
185
+ elapsedMs: getResultTotalStreamMs(streamingResult) ?? 0,
186
+ firstChunkMs: getResultFirstChunkMs(streamingResult),
187
+ totalStreamMs: getResultTotalStreamMs(streamingResult),
188
+ tokensPerSecond: getResultTokensPerSecond(streamingResult),
157
189
  streaming: true,
158
190
  streamingChunks: captureFullDetails ? streamingResult.streamingChunks : undefined,
159
191
  userAgent: "provider-test",