@tonyclaw/llm-inspector 1.14.7 → 1.14.9

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 (34) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-Dv-dj1xH.js +105 -0
  3. package/.output/public/assets/index-bqeypwJB.css +1 -0
  4. package/.output/public/assets/{main-BV7uNIIz.js → main-C8OUJKbz.js} +1 -1
  5. package/.output/server/_libs/lucide-react.mjs +87 -79
  6. package/.output/server/_libs/radix-ui__react-id.mjs +1 -1
  7. package/.output/server/_ssr/{index-BvHLASu8.mjs → index-_9xcAkkw.mjs} +861 -608
  8. package/.output/server/_ssr/index.mjs +2 -2
  9. package/.output/server/_ssr/{router-lUOA8pi6.mjs → router-CmanwZJc.mjs} +45 -14
  10. package/.output/server/{_tanstack-start-manifest_v-XNH7fVPN.mjs → _tanstack-start-manifest_v-BVIiyDeJ.mjs} +1 -1
  11. package/.output/server/index.mjs +23 -23
  12. package/package.json +1 -1
  13. package/src/components/ProxyViewer.tsx +137 -146
  14. package/src/components/providers/ProviderCard.tsx +79 -26
  15. package/src/components/providers/ProviderForm.tsx +37 -22
  16. package/src/components/providers/ProvidersPanel.tsx +79 -47
  17. package/src/components/providers/SettingsDialog.tsx +25 -15
  18. package/src/components/proxy-viewer/ConversationGroup.tsx +74 -11
  19. package/src/components/proxy-viewer/ConversationHeader.tsx +63 -2
  20. package/src/components/proxy-viewer/LogEntry.tsx +184 -54
  21. package/src/components/proxy-viewer/LogEntryHeader.tsx +148 -143
  22. package/src/components/proxy-viewer/ReplayDialog.tsx +16 -6
  23. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +24 -16
  24. package/src/components/proxy-viewer/ThreadConnector.tsx +93 -0
  25. package/src/components/proxy-viewer/index.ts +2 -1
  26. package/src/lib/stopReason.ts +57 -0
  27. package/src/proxy/formats/anthropic/handler.ts +2 -5
  28. package/src/proxy/formats/openai/handler.ts +33 -7
  29. package/src/proxy/formats/openai/schemas.ts +1 -0
  30. package/src/proxy/formats/openai/stream.ts +24 -0
  31. package/src/proxy/handler.ts +8 -2
  32. package/src/proxy/schemas.ts +6 -3
  33. package/.output/public/assets/index-Cmi8TfeU.js +0 -105
  34. package/.output/public/assets/index-DXUNTCVh.css +0 -1
@@ -1,4 +1,13 @@
1
- import { ChevronDown, ChevronRight, Clock, MessageSquare, Zap } from "lucide-react";
1
+ import {
2
+ ChevronDown,
3
+ ChevronRight,
4
+ Clock,
5
+ GitBranch,
6
+ Loader2,
7
+ MessageSquare,
8
+ User,
9
+ Zap,
10
+ } from "lucide-react";
2
11
  import type { JSX } from "react";
3
12
  import { cn, formatTokens } from "../../lib/utils";
4
13
  import type { CapturedLog } from "../../proxy/schemas";
@@ -10,6 +19,8 @@ const API_FORMAT_LABELS: Record<"anthropic" | "openai" | "unknown", string> = {
10
19
  unknown: "Unknown",
11
20
  };
12
21
 
22
+ export type ViewMode = "flat" | "thread";
23
+
13
24
  export type ConversationHeaderProps = {
14
25
  conversationId: string;
15
26
  startTime: string;
@@ -23,6 +34,15 @@ export type ConversationHeaderProps = {
23
34
  /** Hide the API format badge on the header (used when the group contains
24
35
  * mixed formats — the per-log badges are shown instead). */
25
36
  hideApiFormat?: boolean;
37
+ /** When true and the group is collapsed, show a spinner instead of the
38
+ * expand chevron to indicate an in-flight request inside the group. */
39
+ isLoading?: boolean;
40
+ /** Current display mode for this group (flat cards or threaded timeline). */
41
+ viewMode?: ViewMode;
42
+ /** Toggle between flat and thread display modes for this group. */
43
+ onToggleViewMode?: () => void;
44
+ /** User-Agent string from the first log in the group. */
45
+ userAgent?: string | null;
26
46
  };
27
47
 
28
48
  function formatTimestamp(iso: string): string {
@@ -41,6 +61,10 @@ export function ConversationHeader({
41
61
  expanded,
42
62
  onToggle,
43
63
  hideApiFormat = false,
64
+ isLoading = false,
65
+ viewMode,
66
+ onToggleViewMode,
67
+ userAgent,
44
68
  }: ConversationHeaderProps): JSX.Element {
45
69
  return (
46
70
  <div
@@ -60,13 +84,39 @@ export function ConversationHeader({
60
84
  }
61
85
  }}
62
86
  >
63
- {/* Expand chevron */}
87
+ {/* Expand chevron — shows spinner when collapsed and group has pending logs */}
64
88
  {expanded ? (
65
89
  <ChevronDown className="size-4 text-muted-foreground shrink-0" />
90
+ ) : isLoading ? (
91
+ <Loader2 className="size-4 animate-spin text-muted-foreground shrink-0" />
66
92
  ) : (
67
93
  <ChevronRight className="size-4 text-muted-foreground shrink-0" />
68
94
  )}
69
95
 
96
+ {/* Thread/flat view toggle — only shown when expanded */}
97
+ {expanded && onToggleViewMode !== undefined && (
98
+ <button
99
+ type="button"
100
+ onClick={(e) => {
101
+ e.stopPropagation();
102
+ onToggleViewMode();
103
+ }}
104
+ className={cn(
105
+ "px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors shrink-0 cursor-pointer",
106
+ viewMode === "thread"
107
+ ? "bg-amber-500/15 text-amber-400 border border-amber-500/30"
108
+ : "bg-muted text-muted-foreground border border-border hover:text-foreground",
109
+ )}
110
+ title={
111
+ viewMode === "thread"
112
+ ? "Thread view — click for flat view"
113
+ : "Flat view — click for thread view"
114
+ }
115
+ >
116
+ <GitBranch className="size-3" />
117
+ </button>
118
+ )}
119
+
70
120
  {/* Conversation ID */}
71
121
  <span
72
122
  className="text-purple-400/90 font-mono text-xs font-semibold shrink-0"
@@ -77,6 +127,17 @@ export function ConversationHeader({
77
127
  : conversationId}
78
128
  </span>
79
129
 
130
+ {/* User-Agent */}
131
+ {userAgent !== null && userAgent !== undefined && userAgent !== "" && (
132
+ <span
133
+ className="flex items-center gap-1 text-muted-foreground text-xs shrink-0"
134
+ title={userAgent}
135
+ >
136
+ <User className="size-3" />
137
+ <span className="font-mono tabular-nums truncate max-w-[120px]">{userAgent}</span>
138
+ </span>
139
+ )}
140
+
80
141
  {/* API Format Badge */}
81
142
  {!hideApiFormat && (
82
143
  <Badge
@@ -2,9 +2,16 @@ import { Check, Copy, GitCompareArrows, RotateCcw } from "lucide-react";
2
2
  import type { JSX } from "react";
3
3
  import { useMemo, useState, memo } from "react";
4
4
  import { cn } from "../../lib/utils";
5
- import { type CapturedLog, parseRequest } from "../../proxy/schemas";
5
+ import {
6
+ type CapturedLog,
7
+ parseRequest,
8
+ InspectorResponseSchema,
9
+ parseOpenAIResponse,
10
+ OpenAIRequestSchema,
11
+ } from "../../proxy/schemas";
6
12
  import { stripClaudeCodeBillingHeader } from "../../proxy/claudeCodeStrip";
7
13
  import { Button } from "../ui/button";
14
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
8
15
  import { JsonViewerFromString } from "../ui/json-viewer";
9
16
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
10
17
  import { computeHeadersDiff, computeRequestDiff, DiffView } from "./diff";
@@ -17,10 +24,7 @@ import type { CacheTrendEntry } from "./cacheTrend";
17
24
  export type LogEntryProps = {
18
25
  log: CapturedLog;
19
26
  viewMode?: "simple" | "full";
20
- /** Suppress the API format badge when log is displayed within a group */
21
- suppressApiFormatBadge?: boolean;
22
- /**
23
- * Live "strip Claude Code billing header" flag, sourced once at the viewer
27
+ /** Live "strip Claude Code billing header" flag, sourced once at the viewer
24
28
  * container. Hoisted out of `LogEntry` so a single SWR subscription serves
25
29
  * the whole virtualized list (N logs == N subscriptions is the previous
26
30
  * cost).
@@ -31,10 +35,8 @@ export type LogEntryProps = {
31
35
  * `null` (or absent) means the header should render with no arrows.
32
36
  */
33
37
  cacheTrend?: CacheTrendEntry | null;
34
- /** Whether this log is currently marked for comparison. */
35
- isSelected?: boolean;
36
- /** Toggle this log in/out of the comparison selection. */
37
- onToggleSelect?: (logId: number) => void;
38
+ /** Callback to open CompareDrawer with this log and its immediate predecessor. */
39
+ onCompareWithPrevious?: () => void;
38
40
  };
39
41
 
40
42
  /**
@@ -118,32 +120,38 @@ function DiffToggleButton({
118
120
  onClick: (e: React.MouseEvent) => void;
119
121
  }): JSX.Element {
120
122
  return (
121
- <button
122
- type="button"
123
- onClick={onClick}
124
- aria-pressed={active}
125
- className={cn(
126
- "flex items-center gap-1.5 text-xs px-2 py-1 rounded transition-colors",
127
- active
128
- ? "bg-primary/10 text-primary"
129
- : "text-muted-foreground hover:text-foreground hover:bg-muted",
130
- )}
131
- title={active ? "Hide diff with raw" : "Diff this view against the raw version"}
132
- >
133
- <GitCompareArrows className="size-3" />
134
- {active ? "Showing diff" : "Diff with Raw"}
135
- </button>
123
+ <TooltipProvider>
124
+ <Tooltip>
125
+ <TooltipTrigger asChild>
126
+ <button
127
+ type="button"
128
+ onClick={onClick}
129
+ aria-pressed={active}
130
+ className={cn(
131
+ "flex items-center gap-1.5 text-xs px-2 py-1 rounded transition-colors",
132
+ active
133
+ ? "bg-primary/10 text-primary"
134
+ : "text-muted-foreground hover:text-foreground hover:bg-muted",
135
+ )}
136
+ >
137
+ <GitCompareArrows className="size-3" />
138
+ {active ? "Showing diff" : "Diff with Raw"}
139
+ </button>
140
+ </TooltipTrigger>
141
+ <TooltipContent>
142
+ {active ? "Hide diff view" : "Compare proxy output against the original raw version"}
143
+ </TooltipContent>
144
+ </Tooltip>
145
+ </TooltipProvider>
136
146
  );
137
147
  }
138
148
 
139
149
  export const LogEntry = memo(function ({
140
150
  log,
141
151
  viewMode = "simple",
142
- suppressApiFormatBadge = false,
143
152
  strip,
144
153
  cacheTrend = null,
145
- isSelected = false,
146
- onToggleSelect,
154
+ onCompareWithPrevious,
147
155
  }: LogEntryProps): JSX.Element {
148
156
  const [expanded, setExpanded] = useState<boolean>(false);
149
157
  const [requestCopied, setRequestCopied] = useState<boolean>(false);
@@ -152,7 +160,63 @@ export const LogEntry = memo(function ({
152
160
  const [replayOpen, setReplayOpen] = useState<boolean>(false);
153
161
  const [headersDiff, setHeadersDiff] = useState<boolean>(false);
154
162
  const [requestDiff, setRequestDiff] = useState<boolean>(false);
155
- const parsedRequest = useMemo(() => parseRequest(log.rawRequestBody), [log.rawRequestBody]);
163
+ const messageCount = useMemo(() => {
164
+ if (log.rawRequestBody === null) return null;
165
+ if (log.apiFormat === "anthropic") {
166
+ const parsed = parseRequest(log.rawRequestBody);
167
+ if (parsed !== null) return parsed.messages.length;
168
+ } else if (log.apiFormat === "openai") {
169
+ try {
170
+ const result = OpenAIRequestSchema.safeParse(JSON.parse(log.rawRequestBody));
171
+ if (result.success) return result.data.messages.length;
172
+ } catch {
173
+ // ignore
174
+ }
175
+ }
176
+ return null;
177
+ }, [log.rawRequestBody, log.apiFormat]);
178
+ const toolCount = useMemo(() => {
179
+ if (log.rawRequestBody === null) return null;
180
+ if (log.apiFormat === "anthropic") {
181
+ const parsed = parseRequest(log.rawRequestBody);
182
+ if (parsed !== null && parsed.tools !== undefined && parsed.tools.length > 0) {
183
+ return parsed.tools.length;
184
+ }
185
+ } else if (log.apiFormat === "openai") {
186
+ try {
187
+ const result = OpenAIRequestSchema.safeParse(JSON.parse(log.rawRequestBody));
188
+ if (result.success && result.data.tools !== undefined && result.data.tools.length > 0) {
189
+ return result.data.tools.length;
190
+ }
191
+ } catch {
192
+ // ignore
193
+ }
194
+ }
195
+ return null;
196
+ }, [log.rawRequestBody, log.apiFormat]);
197
+ const responseToolNames = useMemo(() => {
198
+ if (log.responseText === null) return null;
199
+ if (log.apiFormat === "openai") {
200
+ const parsed = parseOpenAIResponse(log.responseText);
201
+ if (parsed !== null) {
202
+ const toolCalls = parsed.choices[0]?.message?.tool_calls;
203
+ if (toolCalls !== undefined && toolCalls !== null && toolCalls.length > 0) {
204
+ return toolCalls.map((tc) => tc.function?.name ?? "?").filter((n) => n !== "");
205
+ }
206
+ }
207
+ } else if (log.apiFormat === "anthropic") {
208
+ try {
209
+ const result = InspectorResponseSchema.safeParse(JSON.parse(log.responseText));
210
+ if (result.success) {
211
+ const names = result.data.content.filter((c) => c.type === "tool_use").map((c) => c.name);
212
+ if (names.length > 0) return names;
213
+ }
214
+ } catch {
215
+ // JSON parse failed, ignore
216
+ }
217
+ }
218
+ return null;
219
+ }, [log.responseText, log.apiFormat]);
156
220
  const strippedRequestBody = useMemo(() => {
157
221
  if (!strip || log.apiFormat !== "anthropic" || log.rawRequestBody === null) {
158
222
  return null;
@@ -199,34 +263,70 @@ export const LogEntry = memo(function ({
199
263
 
200
264
  return (
201
265
  <>
202
- <div
203
- className={cn(
204
- "border border-border rounded-lg mb-3 overflow-hidden",
205
- isSelected && "border-l-2 border-l-amber-400",
206
- )}
207
- >
266
+ <div className="border border-border rounded-lg mb-3 overflow-hidden">
208
267
  <LogEntryHeader
209
268
  log={log}
210
- parsedRequest={parsedRequest}
269
+ messageCount={messageCount}
270
+ toolCount={toolCount}
211
271
  expanded={expanded}
212
272
  onToggle={() => setExpanded(!expanded)}
213
- suppressApiFormatBadge={suppressApiFormatBadge}
273
+ responseToolNames={responseToolNames}
214
274
  cacheTrend={cacheTrend}
215
- isSelected={isSelected}
216
- onToggleSelect={onToggleSelect}
217
275
  />
218
276
 
219
277
  {expanded && (
220
278
  <div onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
221
279
  <Tabs defaultValue="request">
222
280
  <TabsList className="mx-4 mt-2">
223
- {viewMode === "full" && <TabsTrigger value="raw-headers">Raw Headers</TabsTrigger>}
224
- {viewMode === "full" && <TabsTrigger value="headers">Headers</TabsTrigger>}
281
+ {viewMode === "full" && (
282
+ <TooltipProvider>
283
+ <Tooltip>
284
+ <TooltipTrigger asChild>
285
+ <TabsTrigger value="raw-headers">Raw Headers</TabsTrigger>
286
+ </TooltipTrigger>
287
+ <TooltipContent>
288
+ HTTP headers received from the upstream provider
289
+ </TooltipContent>
290
+ </Tooltip>
291
+ </TooltipProvider>
292
+ )}
293
+ {viewMode === "full" && (
294
+ <TooltipProvider>
295
+ <Tooltip>
296
+ <TooltipTrigger asChild>
297
+ <TabsTrigger value="headers">Headers</TabsTrigger>
298
+ </TooltipTrigger>
299
+ <TooltipContent>
300
+ Request and response headers sent and received
301
+ </TooltipContent>
302
+ </Tooltip>
303
+ </TooltipProvider>
304
+ )}
225
305
  {shouldShowRawRequestTab(log.apiFormat, viewMode, strip) && (
226
- <TabsTrigger value="raw-request">Raw Request</TabsTrigger>
306
+ <TooltipProvider>
307
+ <Tooltip>
308
+ <TooltipTrigger asChild>
309
+ <TabsTrigger value="raw-request">Raw Request</TabsTrigger>
310
+ </TooltipTrigger>
311
+ <TooltipContent>
312
+ Exact HTTP request sent to the upstream provider
313
+ </TooltipContent>
314
+ </Tooltip>
315
+ </TooltipProvider>
227
316
  )}
228
317
  <TabsTrigger value="request">Request</TabsTrigger>
229
- {viewMode === "full" && <TabsTrigger value="raw">Raw Response</TabsTrigger>}
318
+ {viewMode === "full" && (
319
+ <TooltipProvider>
320
+ <Tooltip>
321
+ <TooltipTrigger asChild>
322
+ <TabsTrigger value="raw">Raw Response</TabsTrigger>
323
+ </TooltipTrigger>
324
+ <TooltipContent>
325
+ Exact HTTP response from the upstream provider
326
+ </TooltipContent>
327
+ </Tooltip>
328
+ </TooltipProvider>
329
+ )}
230
330
  <TabsTrigger value="parsed">Response</TabsTrigger>
231
331
  </TabsList>
232
332
 
@@ -267,18 +367,48 @@ export const LogEntry = memo(function ({
267
367
  }}
268
368
  />
269
369
  )}
270
- <Button
271
- variant="outline"
272
- size="sm"
273
- className="h-7 text-xs"
274
- onClick={(e) => {
275
- e.stopPropagation();
276
- setReplayOpen(true);
277
- }}
278
- >
279
- <RotateCcw className="size-3 mr-1" />
280
- Replay
281
- </Button>
370
+ {onCompareWithPrevious !== undefined && (
371
+ <TooltipProvider>
372
+ <Tooltip>
373
+ <TooltipTrigger asChild>
374
+ <Button
375
+ variant="outline"
376
+ size="sm"
377
+ className="h-7 text-xs"
378
+ onClick={(e) => {
379
+ e.stopPropagation();
380
+ onCompareWithPrevious();
381
+ }}
382
+ >
383
+ <GitCompareArrows className="size-3 mr-1" />
384
+ Diff with Previous
385
+ </Button>
386
+ </TooltipTrigger>
387
+ <TooltipContent>
388
+ Compare this request with the immediately preceding one
389
+ </TooltipContent>
390
+ </Tooltip>
391
+ </TooltipProvider>
392
+ )}
393
+ <TooltipProvider>
394
+ <Tooltip>
395
+ <TooltipTrigger asChild>
396
+ <Button
397
+ variant="outline"
398
+ size="sm"
399
+ className="h-7 text-xs"
400
+ onClick={(e) => {
401
+ e.stopPropagation();
402
+ setReplayOpen(true);
403
+ }}
404
+ >
405
+ <RotateCcw className="size-3 mr-1" />
406
+ Replay
407
+ </Button>
408
+ </TooltipTrigger>
409
+ <TooltipContent>Re-send this request to the provider</TooltipContent>
410
+ </Tooltip>
411
+ </TooltipProvider>
282
412
  <CopyButton
283
413
  text={displayedRequestBody}
284
414
  label="Copy Request"