@tonyclaw/llm-inspector 1.16.4 → 1.17.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 (62) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/CompareDrawer-C4fie5g5.js +1 -0
  3. package/.output/public/assets/ReplayDialog-Dme5uOR9.js +1 -0
  4. package/.output/public/assets/RequestAnatomy-ChBLDNFH.js +1 -0
  5. package/.output/public/assets/ResponseView-wGeqBzVU.js +1 -0
  6. package/.output/public/assets/StreamingChunkSequence-zeJZQLqT.js +1 -0
  7. package/.output/public/assets/index-DoGvsnbA.css +1 -0
  8. package/.output/public/assets/index-DpbutOvo.js +101 -0
  9. package/.output/public/assets/json-viewer-BV-WUszW.js +14 -0
  10. package/.output/public/assets/{main-DbWwVQFh.js → main-DRu10KNQ.js} +1 -1
  11. package/.output/server/_libs/lucide-react.mjs +105 -85
  12. package/.output/server/_ssr/CompareDrawer-C4-CQL5w.mjs +1040 -0
  13. package/.output/server/_ssr/ReplayDialog-BTb1Bam8.mjs +321 -0
  14. package/.output/server/_ssr/RequestAnatomy-CZFV1IvL.mjs +351 -0
  15. package/.output/server/_ssr/ResponseView-CTZekh65.mjs +601 -0
  16. package/.output/server/_ssr/StreamingChunkSequence-C38Ynabd.mjs +301 -0
  17. package/.output/server/_ssr/{index-C-z-fZtq.mjs → index-Cnu-QzAy.mjs} +1141 -2443
  18. package/.output/server/_ssr/index.mjs +2 -2
  19. package/.output/server/_ssr/json-viewer-DROqpjS9.mjs +510 -0
  20. package/.output/server/_ssr/{router-CNM9Kbi0.mjs → router-pP4GCTQx.mjs} +42 -18
  21. package/.output/server/{_tanstack-start-manifest_v-BWfLeIsC.mjs → _tanstack-start-manifest_v-CphS4rZd.mjs} +1 -1
  22. package/.output/server/index.mjs +69 -27
  23. package/package.json +1 -1
  24. package/src/components/OnboardingBanner.tsx +2 -2
  25. package/src/components/ProxyViewer.tsx +44 -27
  26. package/src/components/ProxyViewerContainer.tsx +5 -25
  27. package/src/components/providers/SettingsDialog.tsx +52 -1
  28. package/src/components/proxy-viewer/ConversationGroup.tsx +5 -1
  29. package/src/components/proxy-viewer/ConversationHeader.tsx +4 -1
  30. package/src/components/proxy-viewer/LogEntry.tsx +217 -181
  31. package/src/components/proxy-viewer/LogEntryHeader.tsx +181 -40
  32. package/src/components/proxy-viewer/ThreadConnector.tsx +17 -2
  33. package/src/components/proxy-viewer/TurnGroup.tsx +124 -72
  34. package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +98 -0
  35. package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +196 -0
  36. package/src/components/proxy-viewer/anatomy/tokenEstimate.ts +53 -0
  37. package/src/components/proxy-viewer/anatomy/types.ts +39 -0
  38. package/src/components/proxy-viewer/anatomy/useAnatomyJump.ts +114 -0
  39. package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +3 -23
  40. package/src/components/proxy-viewer/formats/anthropic/thinkingExtract.ts +21 -0
  41. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +5 -3
  42. package/src/components/proxy-viewer/lazy.ts +37 -0
  43. package/src/components/proxy-viewer/log-formats/anthropic.ts +146 -0
  44. package/src/components/proxy-viewer/log-formats/openai.ts +127 -0
  45. package/src/components/proxy-viewer/log-formats/types.ts +7 -0
  46. package/src/components/proxy-viewer/log-formats/unknown.ts +4 -0
  47. package/src/components/proxy-viewer/logEntryVisibility.ts +39 -0
  48. package/src/components/proxy-viewer/useKeyboardNavigation.ts +190 -0
  49. package/src/components/proxy-viewer/viewerState.ts +8 -0
  50. package/src/components/ui/crab-variants.tsx +11 -0
  51. package/src/components/ui/json-expansion-button.tsx +56 -0
  52. package/src/components/ui/json-viewer-bulk.ts +97 -0
  53. package/src/components/ui/json-viewer.tsx +58 -183
  54. package/src/lib/runtimeConfig.ts +9 -0
  55. package/src/lib/useOnboarding.ts +7 -1
  56. package/src/lib/useStripConfig.ts +33 -2
  57. package/src/lib/utils.ts +2 -3
  58. package/src/proxy/config.ts +17 -7
  59. package/src/routes/api/config.ts +7 -0
  60. package/src/routes/api/logs.stream.ts +26 -16
  61. package/.output/public/assets/index-DRRCmu5p.css +0 -1
  62. package/.output/public/assets/index-X7CHS7fS.js +0 -107
@@ -0,0 +1,97 @@
1
+ import { useCallback, useMemo, useState, useTransition } from "react";
2
+
3
+ type JsonPrimitive = string | number | boolean | null;
4
+ export type JsonValue =
5
+ | JsonPrimitive
6
+ | ReadonlyArray<JsonValue>
7
+ | Readonly<{ [key: string]: JsonValue }>;
8
+
9
+ export type JsonExpansionPolicy = {
10
+ depth: number;
11
+ };
12
+
13
+ export function getJsonExpansionPolicy(_value: JsonValue): JsonExpansionPolicy {
14
+ return { depth: Number.POSITIVE_INFINITY };
15
+ }
16
+
17
+ export type JsonBulkExpansion = {
18
+ parsedData: JsonValue | null;
19
+ policy: JsonExpansionPolicy | null;
20
+ isExpanded: boolean;
21
+ toggle: () => void;
22
+ isPending: boolean;
23
+ bulkDepth: number;
24
+ bulkRevision: number;
25
+ };
26
+
27
+ type ParsedJsonText =
28
+ | {
29
+ kind: "json";
30
+ data: JsonValue;
31
+ }
32
+ | {
33
+ kind: "text";
34
+ };
35
+
36
+ export function parseJsonText(text: string): ParsedJsonText {
37
+ try {
38
+ const parsed: unknown = JSON.parse(text);
39
+ return { kind: "json", data: safeJsonValue(parsed) };
40
+ } catch {
41
+ return { kind: "text" };
42
+ }
43
+ }
44
+
45
+ export function useJsonBulkExpansion(text: string | null): JsonBulkExpansion {
46
+ const parsed = useMemo(() => (text === null ? null : parseJsonText(text)), [text]);
47
+ const parsedData = parsed?.kind === "json" ? parsed.data : null;
48
+ const policy = useMemo(
49
+ () => (parsedData === null ? null : getJsonExpansionPolicy(parsedData)),
50
+ [parsedData],
51
+ );
52
+ const [isExpanded, setIsExpanded] = useState(false);
53
+ const [bulkDepth, setBulkDepth] = useState(0);
54
+ const [bulkRevision, setBulkRevision] = useState(0);
55
+ const [isPending, startTransition] = useTransition();
56
+
57
+ const toggle = useCallback(() => {
58
+ const nextExpanded = !isExpanded;
59
+ const targetDepth = nextExpanded && policy !== null ? policy.depth : 0;
60
+ startTransition(() => {
61
+ setIsExpanded(nextExpanded);
62
+ setBulkDepth(targetDepth);
63
+ setBulkRevision((current) => current + 1);
64
+ });
65
+ }, [isExpanded, policy]);
66
+
67
+ return { parsedData, policy, isExpanded, toggle, isPending, bulkDepth, bulkRevision };
68
+ }
69
+
70
+ export function safeJsonValue(value: unknown): JsonValue {
71
+ if (value === null || value === undefined) return null;
72
+ switch (typeof value) {
73
+ case "string":
74
+ return value;
75
+ case "number":
76
+ return value;
77
+ case "boolean":
78
+ return value;
79
+ case "object": {
80
+ if (Array.isArray(value)) {
81
+ return value.map((item: unknown) => safeJsonValue(item));
82
+ }
83
+ const result: Record<string, JsonValue> = {};
84
+ for (const key of Object.keys(value)) {
85
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
86
+ result[key] = safeJsonValue(descriptor?.value);
87
+ }
88
+ return result;
89
+ }
90
+ case "bigint":
91
+ case "symbol":
92
+ case "function":
93
+ case "undefined":
94
+ return String(value);
95
+ }
96
+ return null;
97
+ }
@@ -1,15 +1,11 @@
1
- import { Check, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Copy } from "lucide-react";
2
- import { type JSX, memo, useCallback, useMemo, useState, useTransition } from "react";
1
+ import { Check, ChevronDown, ChevronRight, ChevronsDown, Copy } from "lucide-react";
2
+ import { type JSX, memo, useEffect, useMemo, useState } from "react";
3
3
  import ReactMarkdown from "react-markdown";
4
4
  import { cn } from "../../lib/utils";
5
5
  import { Button } from "./button";
6
6
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
7
-
8
- type JsonPrimitive = string | number | boolean | null;
9
- export type JsonValue =
10
- | JsonPrimitive
11
- | ReadonlyArray<JsonValue>
12
- | Readonly<{ [key: string]: JsonValue }>;
7
+ import type { JsonValue } from "./json-viewer-bulk";
8
+ import { parseJsonText, safeJsonValue } from "./json-viewer-bulk";
13
9
 
14
10
  type DataType = "string" | "number" | "boolean" | "null" | "array" | "object";
15
11
 
@@ -47,133 +43,6 @@ function getEntries(value: JsonValue): ReadonlyArray<readonly [string, JsonValue
47
43
  return [];
48
44
  }
49
45
 
50
- export const JSON_FULL_EXPANSION_NODE_LIMIT = 1000;
51
- export const JSON_LARGE_EXPANSION_DEPTH = 2;
52
-
53
- export type JsonExpansionPolicy = {
54
- depth: number;
55
- limited: boolean;
56
- countedNodes: number;
57
- };
58
-
59
- export function countJsonNodes(value: JsonValue, limit = Number.POSITIVE_INFINITY): number {
60
- let count = 0;
61
- const pending: JsonValue[] = [value];
62
-
63
- while (pending.length > 0) {
64
- const current = pending.pop();
65
- if (current === undefined) continue;
66
- count += 1;
67
- if (count > limit) return count;
68
- if (!isExpandable(current)) continue;
69
- for (const [, child] of getEntries(current)) {
70
- pending.push(child);
71
- }
72
- }
73
-
74
- return count;
75
- }
76
-
77
- export function getJsonExpansionPolicy(value: JsonValue): JsonExpansionPolicy {
78
- const countedNodes = countJsonNodes(value, JSON_FULL_EXPANSION_NODE_LIMIT);
79
- const limited = countedNodes > JSON_FULL_EXPANSION_NODE_LIMIT;
80
- return {
81
- depth: limited ? JSON_LARGE_EXPANSION_DEPTH : Number.POSITIVE_INFINITY,
82
- limited,
83
- countedNodes,
84
- };
85
- }
86
-
87
- export type JsonBulkExpansion = {
88
- parsedData: JsonValue | null;
89
- policy: JsonExpansionPolicy | null;
90
- isExpanded: boolean;
91
- toggle: () => void;
92
- isPending: boolean;
93
- bulkDepth: number;
94
- bulkRevision: number;
95
- };
96
-
97
- export function useJsonBulkExpansion(text: string | null): JsonBulkExpansion {
98
- const parsed = useMemo(() => (text === null ? null : parseJsonText(text)), [text]);
99
- const parsedData = parsed?.kind === "json" ? parsed.data : null;
100
- const policy = useMemo(
101
- () => (parsedData === null ? null : getJsonExpansionPolicy(parsedData)),
102
- [parsedData],
103
- );
104
- const [isExpanded, setIsExpanded] = useState(false);
105
- const [bulkDepth, setBulkDepth] = useState(0);
106
- const [bulkRevision, setBulkRevision] = useState(0);
107
- const [isPending, startTransition] = useTransition();
108
-
109
- const toggle = useCallback(() => {
110
- const nextExpanded = !isExpanded;
111
- const targetDepth = nextExpanded && policy !== null ? policy.depth : 0;
112
- startTransition(() => {
113
- setIsExpanded(nextExpanded);
114
- setBulkDepth(targetDepth);
115
- setBulkRevision((current) => current + 1);
116
- });
117
- }, [isExpanded, policy]);
118
-
119
- return { parsedData, policy, isExpanded, toggle, isPending, bulkDepth, bulkRevision };
120
- }
121
-
122
- export function JsonExpansionButton({
123
- policy,
124
- isExpanded,
125
- isPending,
126
- onToggle,
127
- }: {
128
- policy: JsonExpansionPolicy | null;
129
- isExpanded: boolean;
130
- isPending: boolean;
131
- onToggle: () => void;
132
- }): JSX.Element | null {
133
- if (policy === null) return null;
134
-
135
- const label = isPending
136
- ? "Updating..."
137
- : isExpanded
138
- ? "Collapse all"
139
- : policy.limited
140
- ? `Expand ${JSON_LARGE_EXPANSION_DEPTH} levels`
141
- : "Expand all";
142
-
143
- const tooltip =
144
- policy.limited && !isExpanded
145
- ? `Large JSON detected; expand ${JSON_LARGE_EXPANSION_DEPTH} levels to protect rendering performance`
146
- : isExpanded
147
- ? "Collapse all JSON nodes"
148
- : "Expand all JSON nodes";
149
-
150
- return (
151
- <Tooltip>
152
- <TooltipTrigger asChild>
153
- <Button
154
- variant="outline"
155
- size="sm"
156
- className="h-7 text-xs"
157
- onClick={(e) => {
158
- e.stopPropagation();
159
- onToggle();
160
- }}
161
- disabled={isPending}
162
- aria-pressed={isExpanded}
163
- >
164
- {isExpanded ? (
165
- <ChevronsUp className="size-3 mr-1" />
166
- ) : (
167
- <ChevronsDown className="size-3 mr-1" />
168
- )}
169
- {label}
170
- </Button>
171
- </TooltipTrigger>
172
- <TooltipContent>{tooltip}</TooltipContent>
173
- </Tooltip>
174
- );
175
- }
176
-
177
46
  const STRING_TRUNCATE_LIMIT = 120;
178
47
 
179
48
  function StringValue({ text }: { text: string }): JSX.Element {
@@ -320,6 +189,12 @@ type JsonNodeProps = {
320
189
  level: number;
321
190
  defaultExpandDepth: number;
322
191
  isArrayItem: boolean;
192
+ /** Full JSON-pointer-style path to this node (e.g. "/messages/3"). */
193
+ path: string;
194
+ /** When set, ancestors of this path auto-expand for one render. */
195
+ expandTargetPath: string | null;
196
+ /** Set of paths that should expose `data-anatomy-path` on their row. */
197
+ anatomyPaths: Set<string> | null;
323
198
  };
324
199
 
325
200
  function ExpandCollapseButton({
@@ -345,8 +220,31 @@ const JsonNode = memo(function JsonNode({
345
220
  level,
346
221
  defaultExpandDepth,
347
222
  isArrayItem,
223
+ path,
224
+ expandTargetPath,
225
+ anatomyPaths,
348
226
  }: JsonNodeProps): JSX.Element {
349
- const [expanded, setExpanded] = useState(level < defaultExpandDepth);
227
+ // A node is an ancestor of the target if the target starts with this
228
+ // node's path followed by a path separator. The root path is "" and
229
+ // is an ancestor of every non-empty path.
230
+ const isAncestorOfTarget = useMemo(() => {
231
+ if (expandTargetPath === null) return false;
232
+ if (path === "") return expandTargetPath.length > 0;
233
+ return expandTargetPath === path || expandTargetPath.startsWith(`${path}/`);
234
+ }, [expandTargetPath, path]);
235
+
236
+ const initialExpanded = level < defaultExpandDepth || (isAncestorOfTarget && isExpandable(value));
237
+
238
+ const [expanded, setExpanded] = useState(initialExpanded);
239
+ // Re-evaluate on every render so a freshly-changed expandTargetPath
240
+ // immediately auto-expands a previously-collapsed ancestor.
241
+ useEffect(() => {
242
+ if (isAncestorOfTarget && isExpandable(value) && !expanded) {
243
+ setExpanded(true);
244
+ }
245
+ // We deliberately only react to expandTargetPath changes here.
246
+ }, [expandTargetPath]);
247
+
350
248
  const [childResetKey, setChildResetKey] = useState(0);
351
249
  const [childDepthOverride, setChildDepthOverride] = useState<number | null>(null);
352
250
  const expandable = isExpandable(value);
@@ -361,6 +259,8 @@ const JsonNode = memo(function JsonNode({
361
259
  const openBracket = dataType === "array" ? "[" : "{";
362
260
  const closeBracket = dataType === "array" ? "]" : "}";
363
261
 
262
+ const hasAnatomyPath = anatomyPaths !== null && anatomyPaths.has(path);
263
+
364
264
  function expandAllDeep(): void {
365
265
  setExpanded(true);
366
266
  setChildDepthOverride(Number.POSITIVE_INFINITY);
@@ -395,6 +295,7 @@ const JsonNode = memo(function JsonNode({
395
295
  "flex items-start gap-1 py-0.5 px-1 -ml-1 rounded-sm group/row",
396
296
  expandable && "cursor-pointer hover:bg-muted/50",
397
297
  )}
298
+ data-anatomy-path={hasAnatomyPath ? path : undefined}
398
299
  onClick={expandable ? handleRowToggle : undefined}
399
300
  onKeyDown={
400
301
  expandable
@@ -458,6 +359,9 @@ const JsonNode = memo(function JsonNode({
458
359
  level={level + 1}
459
360
  defaultExpandDepth={effectiveChildDepth}
460
361
  isArrayItem={dataType === "array"}
362
+ path={path === "" ? `/${key}` : `${path}/${key}`}
363
+ expandTargetPath={expandTargetPath}
364
+ anatomyPaths={anatomyPaths}
461
365
  />
462
366
  ))}
463
367
  <div className="text-muted-foreground py-0.5 px-1">{closeBracket}</div>
@@ -474,6 +378,19 @@ export type JsonViewerProps = {
474
378
  showCopy?: boolean;
475
379
  bulkDepth?: number;
476
380
  bulkRevision?: number;
381
+ /**
382
+ * Set of JSON-pointer-style paths whose row should expose a
383
+ * `data-anatomy-path` attribute. Used by the Anatomy view to
384
+ * locate rows for click-to-jump. When `null` or `undefined`,
385
+ * no `data-anatomy-path` attribute is emitted (zero cost).
386
+ */
387
+ anatomyPaths?: Set<string> | null;
388
+ /**
389
+ * When set, every ancestor of this path in the tree auto-expands
390
+ * for one render so the target row becomes visible. The hook
391
+ * flips this back to `null` after the scroll lands.
392
+ */
393
+ expandToPath?: string | null;
477
394
  };
478
395
 
479
396
  export function JsonViewer({
@@ -483,6 +400,8 @@ export function JsonViewer({
483
400
  showCopy = false,
484
401
  bulkDepth: controlledBulkDepth,
485
402
  bulkRevision: controlledBulkRevision,
403
+ anatomyPaths = null,
404
+ expandToPath = null,
486
405
  }: JsonViewerProps): JSX.Element {
487
406
  const expandable = isExpandable(data);
488
407
  const entries = useMemo(() => getEntries(data), [data]);
@@ -523,6 +442,9 @@ export function JsonViewer({
523
442
  level={0}
524
443
  defaultExpandDepth={bulkDepth}
525
444
  isArrayItem={isArray}
445
+ path={`/${key}`}
446
+ expandTargetPath={expandToPath}
447
+ anatomyPaths={anatomyPaths}
526
448
  />
527
449
  ))}
528
450
  </div>
@@ -537,24 +459,6 @@ export type JsonViewerFromStringProps = {
537
459
  className?: string;
538
460
  };
539
461
 
540
- type ParsedJsonText =
541
- | {
542
- kind: "json";
543
- data: JsonValue;
544
- }
545
- | {
546
- kind: "text";
547
- };
548
-
549
- function parseJsonText(text: string): ParsedJsonText {
550
- try {
551
- const parsed: unknown = JSON.parse(text);
552
- return { kind: "json", data: safeJsonValue(parsed) };
553
- } catch {
554
- return { kind: "text" };
555
- }
556
- }
557
-
558
462
  export const JsonViewerFromString = memo(function JsonViewerFromString({
559
463
  text,
560
464
  defaultExpandDepth = 0,
@@ -576,32 +480,3 @@ export const JsonViewerFromString = memo(function JsonViewerFromString({
576
480
  <pre className={cn("font-mono text-xs whitespace-pre-wrap break-words", className)}>{text}</pre>
577
481
  );
578
482
  });
579
-
580
- export function safeJsonValue(value: unknown): JsonValue {
581
- if (value === null || value === undefined) return null;
582
- switch (typeof value) {
583
- case "string":
584
- return value;
585
- case "number":
586
- return value;
587
- case "boolean":
588
- return value;
589
- case "object": {
590
- if (Array.isArray(value)) {
591
- return value.map((item: unknown) => safeJsonValue(item));
592
- }
593
- const result: Record<string, JsonValue> = {};
594
- for (const key of Object.keys(value)) {
595
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
596
- result[key] = safeJsonValue(descriptor?.value);
597
- }
598
- return result;
599
- }
600
- case "bigint":
601
- case "symbol":
602
- case "function":
603
- case "undefined":
604
- return String(value);
605
- }
606
- return null;
607
- }
@@ -1,5 +1,8 @@
1
1
  import { z } from "zod";
2
2
 
3
+ export const DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS = 10;
4
+ export const MAX_SLOW_RESPONSE_THRESHOLD_SECONDS = 600;
5
+
3
6
  /**
4
7
  * Schema for the runtime proxy config. Shared between server
5
8
  * (src/proxy/config.ts) and client (src/lib/useStripConfig.ts) so that
@@ -11,6 +14,12 @@ import { z } from "zod";
11
14
  export const RuntimeConfigSchema = z.object({
12
15
  stripClaudeCodeBillingHeader: z.boolean(),
13
16
  hasSeenOnboarding: z.boolean().default(false),
17
+ slowResponseThresholdSeconds: z
18
+ .number()
19
+ .int()
20
+ .min(0)
21
+ .max(MAX_SLOW_RESPONSE_THRESHOLD_SECONDS)
22
+ .default(DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS),
14
23
  });
15
24
 
16
25
  export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
@@ -1,6 +1,10 @@
1
1
  import useSWR, { type SWRResponse, useSWRConfig } from "swr";
2
2
  import { fetchJson } from "./apiClient";
3
- import { RuntimeConfigSchema, type RuntimeConfig } from "./runtimeConfig";
3
+ import {
4
+ DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS,
5
+ RuntimeConfigSchema,
6
+ type RuntimeConfig,
7
+ } from "./runtimeConfig";
4
8
 
5
9
  export const ONBOARDING_SWR_KEY = "/api/config";
6
10
 
@@ -60,6 +64,8 @@ export function useOnboarding(): UseOnboarding {
60
64
  optimisticData: {
61
65
  stripClaudeCodeBillingHeader: response.data?.stripClaudeCodeBillingHeader ?? false,
62
66
  hasSeenOnboarding: true,
67
+ slowResponseThresholdSeconds:
68
+ response.data?.slowResponseThresholdSeconds ?? DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS,
63
69
  },
64
70
  rollbackOnError: true,
65
71
  revalidate: false,
@@ -1,6 +1,10 @@
1
1
  import useSWR, { type SWRResponse, useSWRConfig } from "swr";
2
2
  import { fetchJson } from "./apiClient";
3
- import { RuntimeConfigSchema, type RuntimeConfig } from "./runtimeConfig";
3
+ import {
4
+ DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS,
5
+ RuntimeConfigSchema,
6
+ type RuntimeConfig,
7
+ } from "./runtimeConfig";
4
8
 
5
9
  export const STRIP_CONFIG_SWR_KEY = "/api/config";
6
10
 
@@ -31,15 +35,18 @@ export async function setRuntimeConfig(patch: Partial<RuntimeConfig>): Promise<R
31
35
 
32
36
  export type UseStripConfig = {
33
37
  strip: boolean;
38
+ slowResponseThresholdSeconds: number;
34
39
  isLoading: boolean;
35
40
  isError: boolean;
36
41
  setStrip: (next: boolean) => Promise<void>;
42
+ setSlowResponseThresholdSeconds: (next: number) => Promise<void>;
37
43
  };
38
44
 
39
45
  /**
40
46
  * Hook reading the runtime config from /api/config.
41
47
  *
42
48
  * - `strip` is the live value from the server (or the optimistic value mid-PATCH).
49
+ * - `slowResponseThresholdSeconds` controls the UI's slow-response indicator.
43
50
  * - `setStrip(next)` PATCHes the server with optimistic update; on failure
44
51
  * it throws and SWR rolls back the optimistic value.
45
52
  */
@@ -55,13 +62,35 @@ export function useStripConfig(): UseStripConfig {
55
62
  const { mutate: globalMutate } = useSWRConfig();
56
63
 
57
64
  const strip = response.data?.stripClaudeCodeBillingHeader ?? false;
65
+ const slowResponseThresholdSeconds =
66
+ response.data?.slowResponseThresholdSeconds ?? DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS;
67
+
68
+ const optimisticConfig = (patch: Partial<RuntimeConfig>): RuntimeConfig => ({
69
+ stripClaudeCodeBillingHeader: response.data?.stripClaudeCodeBillingHeader ?? false,
70
+ hasSeenOnboarding: response.data?.hasSeenOnboarding ?? false,
71
+ slowResponseThresholdSeconds:
72
+ response.data?.slowResponseThresholdSeconds ?? DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS,
73
+ ...patch,
74
+ });
58
75
 
59
76
  const setStrip = async (next: boolean): Promise<void> => {
60
77
  await globalMutate(
61
78
  STRIP_CONFIG_SWR_KEY,
62
79
  setRuntimeConfig({ stripClaudeCodeBillingHeader: next }),
63
80
  {
64
- optimisticData: { stripClaudeCodeBillingHeader: next },
81
+ optimisticData: optimisticConfig({ stripClaudeCodeBillingHeader: next }),
82
+ rollbackOnError: true,
83
+ revalidate: false,
84
+ },
85
+ );
86
+ };
87
+
88
+ const setSlowResponseThresholdSeconds = async (next: number): Promise<void> => {
89
+ await globalMutate(
90
+ STRIP_CONFIG_SWR_KEY,
91
+ setRuntimeConfig({ slowResponseThresholdSeconds: next }),
92
+ {
93
+ optimisticData: optimisticConfig({ slowResponseThresholdSeconds: next }),
65
94
  rollbackOnError: true,
66
95
  revalidate: false,
67
96
  },
@@ -70,8 +99,10 @@ export function useStripConfig(): UseStripConfig {
70
99
 
71
100
  return {
72
101
  strip,
102
+ slowResponseThresholdSeconds,
73
103
  isLoading: response.isLoading,
74
104
  isError: response.error !== undefined,
75
105
  setStrip,
106
+ setSlowResponseThresholdSeconds,
76
107
  };
77
108
  }
package/src/lib/utils.ts CHANGED
@@ -6,9 +6,8 @@ export function cn(...inputs: ClassValue[]): string {
6
6
  }
7
7
 
8
8
  export function formatTokens(count: number): string {
9
- if (count >= 1_048_576) return (count / 1_048_576).toFixed(1).replace(/\.0$/, "") + "M";
10
- if (count >= 1024) return (count / 1024).toFixed(1).replace(/\.0$/, "") + "K";
11
- return count.toString();
9
+ if (count >= 1_000_000) return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
10
+ return (count / 1000).toFixed(1).replace(/\.0$/, "") + "K";
12
11
  }
13
12
 
14
13
  export type StatusCategory = "success" | "client_error" | "server_error" | "pending";
@@ -10,7 +10,11 @@ import {
10
10
  import { join } from "node:path";
11
11
  import { logger } from "./logger";
12
12
  import { getDataDir } from "./dataDir";
13
- import { RuntimeConfigSchema, type RuntimeConfig } from "../lib/runtimeConfig";
13
+ import {
14
+ DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS,
15
+ RuntimeConfigSchema,
16
+ type RuntimeConfig,
17
+ } from "../lib/runtimeConfig";
14
18
 
15
19
  // Re-export so server-side callers (api/config.ts, tests) keep the
16
20
  // existing import path `from "../../proxy/config"`.
@@ -18,10 +22,8 @@ export { RuntimeConfigSchema };
18
22
  export type { RuntimeConfig };
19
23
 
20
24
  /**
21
- * Runtime-mutable server config. Currently exposes a single flag for
22
- * stripping the synthetic `x-anthropic-billing-header:` text block that
23
- * Claude Code prepends to system prompts. The proxy handler reads
24
- * `getConfig().stripClaudeCodeBillingHeader` once per request; the API
25
+ * Runtime-mutable server config. Includes proxy behavior flags and UI tuning
26
+ * settings that should take effect without restarting the dev server. The API
25
27
  * layer reads and writes the same object via `getConfig` / `setConfig`.
26
28
  *
27
29
  * Boot-time precedence (first match wins):
@@ -73,11 +75,19 @@ function resolveInitialConfig(): RuntimeConfig {
73
75
 
74
76
  // 2. Env var
75
77
  if (process.env["LLM_INSPECTOR_STRIP_CLAUDE_CODE_BILLING_HEADER"] === "1") {
76
- return { stripClaudeCodeBillingHeader: true, hasSeenOnboarding: false };
78
+ return {
79
+ stripClaudeCodeBillingHeader: true,
80
+ hasSeenOnboarding: false,
81
+ slowResponseThresholdSeconds: DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS,
82
+ };
77
83
  }
78
84
 
79
85
  // 3. Default off
80
- return { stripClaudeCodeBillingHeader: false, hasSeenOnboarding: false };
86
+ return {
87
+ stripClaudeCodeBillingHeader: false,
88
+ hasSeenOnboarding: false,
89
+ slowResponseThresholdSeconds: DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS,
90
+ };
81
91
  }
82
92
 
83
93
  /**
@@ -1,5 +1,6 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { z } from "zod";
3
+ import { MAX_SLOW_RESPONSE_THRESHOLD_SECONDS } from "../../lib/runtimeConfig";
3
4
  import { getConfig, setConfig, RuntimeConfigSchema } from "../../proxy/config";
4
5
 
5
6
  // Partial schema for PATCH: at least one known field must be present.
@@ -7,6 +8,12 @@ const RuntimeConfigPatchSchema = z
7
8
  .object({
8
9
  stripClaudeCodeBillingHeader: z.boolean().optional(),
9
10
  hasSeenOnboarding: z.boolean().optional(),
11
+ slowResponseThresholdSeconds: z
12
+ .number()
13
+ .int()
14
+ .min(0)
15
+ .max(MAX_SLOW_RESPONSE_THRESHOLD_SECONDS)
16
+ .optional(),
10
17
  })
11
18
  .strict()
12
19
  .refine((v) => Object.keys(v).length > 0, {
@@ -11,20 +11,10 @@ export const Route = createFileRoute("/api/logs/stream")({
11
11
  const model = url.searchParams.get("model") ?? undefined;
12
12
 
13
13
  let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null;
14
-
15
- // Send heartbeat comment every 30s to keep connection alive
16
- const heartbeat = setInterval(() => {
17
- if (controllerRef) {
18
- try {
19
- controllerRef.enqueue(new TextEncoder().encode(": heartbeat\n\n"));
20
- } catch {
21
- // Stream closed
22
- }
23
- }
24
- }, 30000);
14
+ let cleanedUp = false;
25
15
 
26
16
  const unsubscribe = onLogUpdate((log: CapturedLog) => {
27
- if (!controllerRef) return;
17
+ if (controllerRef === null) return;
28
18
  // Filter by session/model if specified
29
19
  if (sessionId !== undefined && log.sessionId !== sessionId) return;
30
20
  if (model !== undefined && log.model !== model) return;
@@ -32,10 +22,32 @@ export const Route = createFileRoute("/api/logs/stream")({
32
22
  const data = `data: ${JSON.stringify({ type: "update", log })}\n\n`;
33
23
  controllerRef.enqueue(new TextEncoder().encode(data));
34
24
  } catch {
35
- // Stream closed
25
+ cleanup();
36
26
  }
37
27
  });
38
28
 
29
+ const cleanup = (): void => {
30
+ if (cleanedUp) return;
31
+ cleanedUp = true;
32
+ clearInterval(heartbeat);
33
+ unsubscribe();
34
+ controllerRef = null;
35
+ request.signal.removeEventListener("abort", cleanup);
36
+ };
37
+
38
+ // Send heartbeat comment every 30s to keep connection alive
39
+ const heartbeat = setInterval(() => {
40
+ if (controllerRef !== null) {
41
+ try {
42
+ controllerRef.enqueue(new TextEncoder().encode(": heartbeat\n\n"));
43
+ } catch {
44
+ cleanup();
45
+ }
46
+ }
47
+ }, 30000);
48
+
49
+ request.signal.addEventListener("abort", cleanup, { once: true });
50
+
39
51
  const stream = new ReadableStream<Uint8Array>({
40
52
  start(controller) {
41
53
  controllerRef = controller;
@@ -45,9 +57,7 @@ export const Route = createFileRoute("/api/logs/stream")({
45
57
  controller.enqueue(new TextEncoder().encode(initData));
46
58
  },
47
59
  cancel() {
48
- clearInterval(heartbeat);
49
- unsubscribe();
50
- controllerRef = null;
60
+ cleanup();
51
61
  },
52
62
  });
53
63