@tonyclaw/llm-inspector 1.7.8 → 1.8.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 (35) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-BLVa7n9b.css +1 -0
  3. package/.output/public/assets/index-DH3FOgcK.js +97 -0
  4. package/.output/public/assets/main-Beo3LJDa.js +17 -0
  5. package/.output/server/_libs/dequal.mjs +27 -0
  6. package/.output/server/_libs/lucide-react.mjs +98 -91
  7. package/.output/server/_libs/swr.mjs +938 -0
  8. package/.output/server/_libs/use-sync-external-store.mjs +64 -1
  9. package/.output/server/_libs/zod.mjs +1 -0
  10. package/.output/server/_ssr/{index-hNquJMfH.mjs → index-HkueJ4Un.mjs} +218 -71
  11. package/.output/server/_ssr/index.mjs +2 -2
  12. package/.output/server/_ssr/{router-MmnX-LYh.mjs → router-DTswxb7l.mjs} +264 -52
  13. package/.output/server/_tanstack-start-manifest_v-DhUuivt-.mjs +4 -0
  14. package/.output/server/index.mjs +26 -26
  15. package/package.json +2 -1
  16. package/src/components/ProxyViewer.tsx +2 -0
  17. package/src/components/providers/ProviderCard.tsx +38 -33
  18. package/src/components/providers/ProviderLogo.tsx +6 -1
  19. package/src/components/providers/ProvidersPanel.tsx +144 -43
  20. package/src/components/providers/SettingsDialog.tsx +5 -3
  21. package/src/components/proxy-viewer/ConversationGroup.tsx +3 -3
  22. package/src/components/proxy-viewer/LogEntry.tsx +6 -3
  23. package/src/components/proxy-viewer/LogEntryHeader.tsx +3 -2
  24. package/src/lib/useProviders.ts +30 -0
  25. package/src/proxy/formats/anthropic/stream.ts +3 -2
  26. package/src/proxy/formats/openai/stream.ts +3 -2
  27. package/src/proxy/handler.ts +5 -0
  28. package/src/proxy/providers.ts +98 -0
  29. package/src/routes/__root.tsx +4 -1
  30. package/src/routes/api/providers.export.ts +26 -0
  31. package/src/routes/api/providers.import.ts +47 -0
  32. package/.output/public/assets/index-B3RwBPLW.css +0 -1
  33. package/.output/public/assets/index-C8o6bEv6.js +0 -97
  34. package/.output/public/assets/main-Bxc5pKCu.js +0 -17
  35. package/.output/server/_tanstack-start-manifest_v-CYKtU_9S.mjs +0 -4
@@ -3,6 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
3
3
  import { Download, LayoutGrid, List } from "lucide-react";
4
4
  import type { CapturedLog } from "../proxy/schemas";
5
5
  import { exportLogsAsZip } from "../lib/export-logs";
6
+ import packageJson from "../../package.json";
6
7
  import {
7
8
  ConversationGroup,
8
9
  groupLogsByConversation,
@@ -142,6 +143,7 @@ export function ProxyViewer({
142
143
  {/* Header */}
143
144
  <div className="flex items-center gap-4 mb-4 px-6 pt-6">
144
145
  <h1 className="text-lg font-bold flex-1">LLM Proxy Inspector</h1>
146
+ <span className="text-muted-foreground text-xs font-mono">v{packageJson.version}</span>
145
147
  <div className="flex items-center border border-border rounded-md overflow-hidden">
146
148
  <button
147
149
  type="button"
@@ -49,9 +49,11 @@ type TestResult = {
49
49
 
50
50
  type NotConfigured = { notConfigured: true };
51
51
 
52
+ type Testing = { testing: true };
53
+
52
54
  type StreamingTestResults = {
53
- nonStreaming: TestResult | NotConfigured;
54
- streaming: TestResult | NotConfigured;
55
+ nonStreaming: TestResult | NotConfigured | Testing;
56
+ streaming: TestResult | NotConfigured | Testing;
55
57
  };
56
58
 
57
59
  type TestResults = {
@@ -74,10 +76,17 @@ function maskApiKey(apiKey: string): string {
74
76
  return apiKey.slice(0, 4) + "••••••••" + apiKey.slice(-4);
75
77
  }
76
78
 
77
- function hasSuccessField(result: TestResult | NotConfigured): result is TestResult {
79
+ function hasSuccessField(result: TestResult | NotConfigured | Testing): result is TestResult {
78
80
  return Object.prototype.hasOwnProperty.call(result, "success");
79
81
  }
80
82
 
83
+ // Using Object.prototype.hasOwnProperty.call to avoid 'in' operator
84
+ function isNotConfiguredState(
85
+ result: TestResult | NotConfigured | Testing,
86
+ ): result is NotConfigured {
87
+ return Object.prototype.hasOwnProperty.call(result, "notConfigured");
88
+ }
89
+
81
90
  function getErrorIcon(type: ErrorType): JSX.Element {
82
91
  const iconProps = { className: "size-3", strokeWidth: 2 };
83
92
  switch (type) {
@@ -102,24 +111,28 @@ function getErrorIcon(type: ErrorType): JSX.Element {
102
111
  }
103
112
  }
104
113
 
105
- function TestStatus({
106
- result,
107
- isTesting,
108
- }: {
109
- result: TestResult | NotConfigured;
110
- isTesting?: boolean;
111
- }): JSX.Element {
114
+ function TestStatus({ result }: { result: TestResult | NotConfigured | Testing }): JSX.Element {
112
115
  if (!hasSuccessField(result)) {
116
+ // Not TestResult - check if NotConfigured or Testing
117
+ if (isNotConfiguredState(result)) {
118
+ return (
119
+ <div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
120
+ <Minus className="size-3" />
121
+ <span>Not configured</span>
122
+ </div>
123
+ );
124
+ }
125
+ // Must be Testing state
113
126
  return (
114
- <div className="flex items-center gap-1 text-xs text-muted-foreground">
115
- <Minus className="size-3" />
116
- <span>Not configured</span>
127
+ <div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
128
+ <RotateCw className="size-3 animate-spin" />
129
+ <span>Testing...</span>
117
130
  </div>
118
131
  );
119
132
  }
120
133
  if (result.success) {
121
134
  return (
122
- <div className="flex items-center gap-1 text-xs text-green-600">
135
+ <div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
123
136
  <CheckCircle className="size-3" />
124
137
  <span>Connected</span>
125
138
  </div>
@@ -131,20 +144,16 @@ function TestStatus({
131
144
  const errorHint = error?.hint;
132
145
  const errorType = error?.type ?? "unknown";
133
146
 
147
+ // Combine message and hint in a single line for consistent layout
148
+ const fullMessage = errorHint !== undefined ? `${errorMessage} — ${errorHint}` : errorMessage;
149
+
134
150
  return (
135
- <div className="flex flex-col gap-1 min-w-0">
136
- <div
137
- className="flex items-center gap-1 text-xs text-red-600 min-w-0"
138
- title={error?.details ?? errorMessage}
139
- >
140
- {getErrorIcon(errorType)}
141
- <span className="truncate">{errorMessage}</span>
142
- </div>
143
- {errorHint !== undefined && (
144
- <div className="text-xs text-muted-foreground pl-4 truncate" title={errorHint}>
145
- {errorHint}
146
- </div>
147
- )}
151
+ <div
152
+ className="flex items-center gap-1 text-xs text-red-600 shrink-0 max-w-[200px]"
153
+ title={error?.details ?? fullMessage}
154
+ >
155
+ {getErrorIcon(errorType)}
156
+ <span className="truncate">{errorMessage}</span>
148
157
  </div>
149
158
  );
150
159
  }
@@ -207,9 +216,7 @@ export function ProviderCard({
207
216
  <span className="font-medium">Anthropic:</span>{" "}
208
217
  <span className="truncate">{provider.anthropicBaseUrl}</span>
209
218
  </div>
210
- {testResults && (
211
- <TestStatus result={testResults.anthropic.nonStreaming} isTesting={isTesting} />
212
- )}
219
+ {testResults && <TestStatus result={testResults.anthropic.nonStreaming} />}
213
220
  </div>
214
221
  )}
215
222
 
@@ -219,9 +226,7 @@ export function ProviderCard({
219
226
  <span className="font-medium">OpenAI:</span>{" "}
220
227
  <span className="truncate">{provider.openaiBaseUrl}</span>
221
228
  </div>
222
- {testResults && (
223
- <TestStatus result={testResults.openai.nonStreaming} isTesting={isTesting} />
224
- )}
229
+ {testResults && <TestStatus result={testResults.openai.nonStreaming} />}
225
230
  </div>
226
231
  )}
227
232
 
@@ -50,7 +50,12 @@ const AnthropicLogo = React.memo(
50
50
 
51
51
  const OpenAILogo = React.memo(
52
52
  ({ className }: { className?: string }): JSX.Element => (
53
- <img src={OpenAILogoSvg} alt="OpenAI" className={className} style={sizeStyle} />
53
+ <img
54
+ src={OpenAILogoSvg}
55
+ alt="OpenAI"
56
+ className={className}
57
+ style={{ ...sizeStyle, background: "white", borderRadius: "4px" }}
58
+ />
54
59
  ),
55
60
  );
56
61
 
@@ -1,6 +1,7 @@
1
1
  import { type JSX, useState, useEffect, useCallback, useRef } from "react";
2
+ import { z } from "zod";
2
3
  import { Button } from "../ui/button";
3
- import { Plus, AlertCircle, Copy, Check } from "lucide-react";
4
+ import { Plus, AlertCircle, Copy, Check, Download, Upload } from "lucide-react";
4
5
  import { ProviderCard } from "./ProviderCard";
5
6
  import { ProviderForm } from "./ProviderForm";
6
7
  import type { ProviderConfig } from "../../proxy/providers";
@@ -31,9 +32,11 @@ type TestResult = {
31
32
 
32
33
  type NotConfigured = { notConfigured: true };
33
34
 
35
+ type Testing = { testing: true };
36
+
34
37
  type StreamingTestResults = {
35
- nonStreaming: TestResult | NotConfigured;
36
- streaming: TestResult | NotConfigured;
38
+ nonStreaming: TestResult | NotConfigured | Testing;
39
+ streaming: TestResult | NotConfigured | Testing;
37
40
  };
38
41
 
39
42
  export type TestResults = {
@@ -46,7 +49,7 @@ type ProvidersPanelProps = {
46
49
  externalTestResults?: Record<string, TestResults>;
47
50
  externalTestingProviders?: Set<string>;
48
51
  externalTestingTimeLeft?: Record<string, number>;
49
- onProvidersChange?: (providers: ProviderConfig[]) => void;
52
+ onProvidersMutate?: () => void;
50
53
  onTestResultsChange?: (providerId: string, results: TestResults) => void;
51
54
  onTestingProvidersChange?: (providerId: string, isTesting: boolean) => void;
52
55
  onTestingTimeLeftChange?: (providerId: string, seconds: number | undefined) => void;
@@ -57,13 +60,11 @@ export function ProvidersPanel({
57
60
  externalTestResults,
58
61
  externalTestingProviders,
59
62
  externalTestingTimeLeft,
60
- onProvidersChange,
63
+ onProvidersMutate,
61
64
  onTestResultsChange,
62
65
  onTestingProvidersChange,
63
66
  onTestingTimeLeftChange,
64
67
  }: ProvidersPanelProps): JSX.Element {
65
- const [internalProviders, setInternalProviders] = useState<ProviderConfig[]>([]);
66
- const [isLoading, setIsLoading] = useState(true);
67
68
  const [showForm, setShowForm] = useState(false);
68
69
  const [editingProvider, setEditingProvider] = useState<ProviderConfig | undefined>();
69
70
  const [error, setError] = useState<string | null>(null);
@@ -75,9 +76,8 @@ export function ProvidersPanel({
75
76
  const [configPath, setConfigPath] = useState<string | null>(null);
76
77
  const [configPathCopied, setConfigPathCopied] = useState(false);
77
78
 
78
- // Use external state if provided, otherwise use internal state
79
- const providers = externalProviders ?? internalProviders;
80
- const setProviders = onProvidersChange ?? setInternalProviders;
79
+ // Use external state if provided (from SWR), otherwise use internal state
80
+ const providers = externalProviders ?? [];
81
81
  const testResults = externalTestResults ?? internalTestResults;
82
82
  const setTestResults = onTestResultsChange
83
83
  ? (id: string, results: TestResults) => onTestResultsChange(id, results)
@@ -101,21 +101,7 @@ export function ProvidersPanel({
101
101
  }
102
102
  };
103
103
 
104
- const fetchProviders = useCallback(async (): Promise<void> => {
105
- try {
106
- const providersRes = await fetch("/api/providers");
107
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
108
- const providersData = (await providersRes.json()) as ProviderConfig[];
109
- setProviders(providersData);
110
- } catch {
111
- setError("Failed to load providers");
112
- } finally {
113
- setIsLoading(false);
114
- }
115
- }, []);
116
-
117
104
  useEffect(() => {
118
- void fetchProviders();
119
105
  void (async () => {
120
106
  try {
121
107
  const res = await fetch("/api/config/paths");
@@ -128,12 +114,23 @@ export function ProvidersPanel({
128
114
  // Ignore
129
115
  }
130
116
  })();
131
- }, [fetchProviders]);
117
+ }, []);
132
118
 
133
119
  const TEST_TIMEOUT_SECONDS = 30;
134
120
 
135
121
  const runTest = useCallback(
136
122
  async (providerId: string): Promise<void> => {
123
+ // Clear previous test results when starting a new test
124
+ const resetResults: TestResults = {
125
+ anthropic: { nonStreaming: { testing: true }, streaming: { testing: true } },
126
+ openai: { nonStreaming: { testing: true }, streaming: { testing: true } },
127
+ };
128
+ if (onTestResultsChange) {
129
+ onTestResultsChange(providerId, resetResults);
130
+ } else {
131
+ setInternalTestResults((prev) => ({ ...prev, [providerId]: resetResults }));
132
+ }
133
+
137
134
  // Use callback form if available, otherwise direct set
138
135
  if (onTestingProvidersChange) {
139
136
  onTestingProvidersChange(providerId, true);
@@ -196,7 +193,8 @@ export function ProvidersPanel({
196
193
  }
197
194
  } catch (err) {
198
195
  // Check if this was an abort (timeout)
199
- if (err instanceof Error && err.name === "AbortError") {
196
+ const isAbort = err instanceof Error && err.name === "AbortError";
197
+ if (isAbort) {
200
198
  const timeoutResult: TestResults = {
201
199
  anthropic: {
202
200
  nonStreaming: {
@@ -219,17 +217,25 @@ export function ProvidersPanel({
219
217
  setInternalTestResults((prev) => ({ ...prev, [providerId]: timeoutResult }));
220
218
  }
221
219
  }
220
+ // If it's not an abort error, the test results won't be updated
221
+ // which means the previous results will persist (or it will show "Not configured" reset state)
222
222
  } finally {
223
+ // Always clear the countdown and testing state, even if an error occurs
223
224
  clearInterval(intervalId);
224
225
  setTestingTimeLeft(providerId, undefined);
225
- if (onTestingProvidersChange) {
226
- onTestingProvidersChange(providerId, false);
227
- } else {
228
- setInternalTestingProviders((prev) => {
229
- const next = new Set(prev);
230
- next.delete(providerId);
231
- return next;
232
- });
226
+ // Ensure testingProviders state is cleared - wrap in try to guarantee execution
227
+ try {
228
+ if (onTestingProvidersChange) {
229
+ onTestingProvidersChange(providerId, false);
230
+ } else {
231
+ setInternalTestingProviders((prev) => {
232
+ const next = new Set(prev);
233
+ next.delete(providerId);
234
+ return next;
235
+ });
236
+ }
237
+ } catch {
238
+ // Ignore errors in state updates to ensure we always clean up
233
239
  }
234
240
  }
235
241
  },
@@ -266,7 +272,7 @@ export function ProvidersPanel({
266
272
  }
267
273
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
268
274
  const newProvider = (await res.json()) as ProviderConfig;
269
- await fetchProviders();
275
+ onProvidersMutate?.();
270
276
  setShowForm(false);
271
277
  // Run test on new provider
272
278
  await runTest(newProvider.id);
@@ -304,7 +310,7 @@ export function ProvidersPanel({
304
310
  }
305
311
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
306
312
  const updated = (await res.json()) as ProviderConfig;
307
- await fetchProviders();
313
+ onProvidersMutate?.();
308
314
  setEditingProvider(undefined);
309
315
  // Run test on updated provider
310
316
  await runTest(updated.id);
@@ -324,12 +330,80 @@ export function ProvidersPanel({
324
330
  setError(err.error ?? "Failed to delete provider");
325
331
  return;
326
332
  }
327
- await fetchProviders();
333
+ onProvidersMutate?.();
334
+ })();
335
+ }
336
+
337
+ const fileInputRef = useRef<HTMLInputElement>(null);
338
+
339
+ function handleExport(includeKeys: boolean): void {
340
+ const url = `/api/providers/export${includeKeys ? "?includeKeys=true" : ""}`;
341
+ void (async () => {
342
+ try {
343
+ const res = await fetch(url);
344
+ if (!res.ok) {
345
+ setError("Failed to export providers");
346
+ return;
347
+ }
348
+ const blob = await res.blob();
349
+ const downloadUrl = URL.createObjectURL(blob);
350
+ const a = document.createElement("a");
351
+ a.href = downloadUrl;
352
+ a.download =
353
+ res.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "providers.json";
354
+ document.body.appendChild(a);
355
+ a.click();
356
+ document.body.removeChild(a);
357
+ URL.revokeObjectURL(downloadUrl);
358
+ } catch {
359
+ setError("Failed to export providers");
360
+ }
361
+ })();
362
+ }
363
+
364
+ function handleImportClick(): void {
365
+ fileInputRef.current?.click();
366
+ }
367
+
368
+ function handleFileChange(e: React.ChangeEvent<HTMLInputElement>): void {
369
+ const file = e.target.files?.[0];
370
+ if (!file) return;
371
+
372
+ void (async () => {
373
+ try {
374
+ const text = await file.text();
375
+ const res = await fetch("/api/providers/import", {
376
+ method: "POST",
377
+ headers: { "Content-Type": "application/json" },
378
+ body: JSON.stringify(text),
379
+ });
380
+ const ImportResponseSchema = z.object({
381
+ success: z.boolean().optional(),
382
+ imported: z.number().optional(),
383
+ message: z.string().optional(),
384
+ errors: z.array(z.string()).optional(),
385
+ });
386
+ const data = ImportResponseSchema.parse(await res.json());
387
+ if (res.ok && data.imported !== undefined && data.imported > 0) {
388
+ onProvidersMutate?.();
389
+ // Show success message via error state temporarily
390
+ setError(null);
391
+ // Use a ref or state to show success - for now just refresh list
392
+ } else if (data.errors && data.errors.length > 0) {
393
+ setError(data.errors.join("; "));
394
+ } else {
395
+ setError(data.message ?? "Import failed");
396
+ }
397
+ } catch {
398
+ setError("Failed to import providers");
399
+ }
400
+ // Reset file input
401
+ e.target.value = "";
328
402
  })();
329
403
  }
330
404
 
331
405
  // Only show loading if we have no providers at all (prevents flashing when reopening Settings during test)
332
- if (isLoading && providers.length === 0) {
406
+ if (providers.length === 0) {
333
407
  return (
334
408
  <div className="flex items-center justify-center py-8">
335
409
  <p className="text-sm text-muted-foreground">Loading providers...</p>
@@ -359,12 +433,39 @@ export function ProvidersPanel({
359
433
 
360
434
  return (
361
435
  <div className="space-y-4">
362
- <div className="flex items-center justify-between">
436
+ <div className="flex items-center justify-between sticky top-0 z-10 bg-background pb-2">
363
437
  <h3 className="text-lg font-medium">Providers</h3>
364
- <Button onClick={() => setShowForm(true)} size="sm" className="gap-1">
365
- <Plus className="size-4" />
366
- Add Provider
367
- </Button>
438
+ <div className="flex items-center gap-2">
439
+ <Button
440
+ variant="outline"
441
+ size="sm"
442
+ onClick={() => handleExport(false)}
443
+ className="gap-1 hover:bg-muted"
444
+ >
445
+ <Download className="size-3" />
446
+ Export
447
+ </Button>
448
+ <Button
449
+ variant="outline"
450
+ size="sm"
451
+ onClick={handleImportClick}
452
+ className="gap-1 hover:bg-muted"
453
+ >
454
+ <Upload className="size-3" />
455
+ Import
456
+ </Button>
457
+ <input
458
+ type="file"
459
+ ref={fileInputRef}
460
+ accept=".json"
461
+ onChange={handleFileChange}
462
+ style={{ display: "none" }}
463
+ />
464
+ <Button onClick={() => setShowForm(true)} size="sm" className="gap-1">
465
+ <Plus className="size-4" />
466
+ Add Provider
467
+ </Button>
468
+ </div>
368
469
  </div>
369
470
 
370
471
  {configPath !== null && (
@@ -4,12 +4,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
4
4
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
5
5
  import { Button } from "../ui/button";
6
6
  import { ProvidersPanel } from "./ProvidersPanel";
7
- import type { ProviderConfig } from "../../proxy/providers";
7
+ import { useProviders } from "../../lib/useProviders";
8
8
 
9
9
  export function SettingsDialog(): JSX.Element {
10
10
  const [open, setOpen] = useState(false);
11
11
  const [activeTab, setActiveTab] = useState("providers");
12
- const [providers, setProviders] = useState<ProviderConfig[]>([]);
12
+ const { providers, mutate } = useProviders();
13
13
  const [testResults, setTestResults] = useState<
14
14
  Record<string, import("./ProvidersPanel").TestResults>
15
15
  >({});
@@ -74,7 +74,9 @@ export function SettingsDialog(): JSX.Element {
74
74
  externalTestResults={testResults}
75
75
  externalTestingProviders={testingProviders}
76
76
  externalTestingTimeLeft={testingTimeLeft}
77
- onProvidersChange={setProviders}
77
+ onProvidersMutate={() => {
78
+ void mutate();
79
+ }}
78
80
  onTestResultsChange={handleTestResultsChange}
79
81
  onTestingProvidersChange={handleTestingProvidersChange}
80
82
  onTestingTimeLeftChange={handleTestingTimeLeftChange}
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useState, memo } from "react";
2
2
  import type { JSX } from "react";
3
3
  import type { CapturedLog } from "../../proxy/schemas";
4
4
  import {
@@ -26,7 +26,7 @@ function computeStats(logs: CapturedLog[]): {
26
26
  return { totalInputTokens: totalInput, totalOutputTokens: totalOutput };
27
27
  }
28
28
 
29
- export function ConversationGroup({
29
+ export const ConversationGroup = memo(function ConversationGroup({
30
30
  group,
31
31
  viewMode = "simple",
32
32
  }: ConversationGroupProps): JSX.Element {
@@ -65,4 +65,4 @@ export function ConversationGroup({
65
65
  )}
66
66
  </div>
67
67
  );
68
- }
68
+ });
@@ -1,6 +1,6 @@
1
1
  import { Check, Copy, RotateCcw } from "lucide-react";
2
2
  import type { JSX } from "react";
3
- import { useMemo, useState } from "react";
3
+ import { useMemo, useState, memo } from "react";
4
4
  import { cn } from "../../lib/utils";
5
5
  import { type CapturedLog, parseRequest } from "../../proxy/schemas";
6
6
  import { Button } from "../ui/button";
@@ -49,7 +49,10 @@ function CopyButton({
49
49
  );
50
50
  }
51
51
 
52
- export function LogEntry({ log, viewMode = "simple" }: LogEntryProps): JSX.Element {
52
+ export const LogEntry = memo(function LogEntry({
53
+ log,
54
+ viewMode = "simple",
55
+ }: LogEntryProps): JSX.Element {
53
56
  const [expanded, setExpanded] = useState<boolean>(false);
54
57
  const [requestCopied, setRequestCopied] = useState<boolean>(false);
55
58
  const [responseCopied, setResponseCopied] = useState<boolean>(false);
@@ -222,4 +225,4 @@ export function LogEntry({ log, viewMode = "simple" }: LogEntryProps): JSX.Eleme
222
225
  <ReplayDialog log={log} open={replayOpen} onOpenChange={setReplayOpen} />
223
226
  </>
224
227
  );
225
- }
228
+ });
@@ -12,6 +12,7 @@ import {
12
12
  Zap,
13
13
  } from "lucide-react";
14
14
  import type { JSX } from "react";
15
+ import { memo } from "react";
15
16
  import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../lib/utils";
16
17
  import type { CapturedLog, InspectorRequest } from "../../proxy/schemas";
17
18
  import { Badge } from "../ui/badge";
@@ -42,7 +43,7 @@ export type LogEntryHeaderProps = {
42
43
  onToggle: () => void;
43
44
  };
44
45
 
45
- export function LogEntryHeader({
46
+ export const LogEntryHeader = memo(function LogEntryHeader({
46
47
  log,
47
48
  parsedRequest,
48
49
  expanded,
@@ -247,4 +248,4 @@ export function LogEntryHeader({
247
248
  )}
248
249
  </div>
249
250
  );
250
- }
251
+ });
@@ -0,0 +1,30 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/consistent-type-assertions */
2
+ import useSWR, { type SWRResponse } from "swr";
3
+ import type { ProviderConfig } from "../proxy/providers";
4
+
5
+ async function fetcher(url: string): Promise<ProviderConfig[]> {
6
+ const response = await fetch(url);
7
+ const data = await response.json();
8
+ if (!Array.isArray(data)) {
9
+ return [];
10
+ }
11
+ return data as ProviderConfig[];
12
+ }
13
+
14
+ export function useProviders() {
15
+ const response: SWRResponse<ProviderConfig[], Error> = useSWR<ProviderConfig[], Error>(
16
+ "/api/providers",
17
+ fetcher,
18
+ {
19
+ revalidateOnFocus: false,
20
+ revalidateIfStale: false,
21
+ },
22
+ );
23
+
24
+ return {
25
+ providers: response.data ?? [],
26
+ isLoading: response.isLoading,
27
+ isError: response.error,
28
+ mutate: response.mutate,
29
+ };
30
+ }
@@ -63,9 +63,10 @@ export function extractAnthropicStream(
63
63
  const chunks: Array<{ index: number; timestamp: number; type: string; data: JsonValue }> = [];
64
64
 
65
65
  for (const line of raw.split("\n")) {
66
- if (!line.startsWith("data: ")) continue;
66
+ const trimmedLine = line.trim();
67
+ if (!trimmedLine.startsWith("data: ")) continue;
67
68
  try {
68
- const json: unknown = JSON.parse(line.slice(6));
69
+ const json: unknown = JSON.parse(trimmedLine.slice(6));
69
70
  const parsed = SseEventSchema.safeParse(json);
70
71
  if (!parsed.success) continue;
71
72
  const data = parsed.data;
@@ -35,8 +35,9 @@ export function extractOpenAIStream(
35
35
  const chunks: Array<{ index: number; timestamp: number; type: string; data: JsonValue }> = [];
36
36
 
37
37
  for (const line of raw.split("\n")) {
38
- if (!line.startsWith("data: ")) continue;
39
- const dataStr = line.slice(6).trim();
38
+ const trimmedLine = line.trim();
39
+ if (!trimmedLine.startsWith("data: ")) continue;
40
+ const dataStr = trimmedLine.slice(6);
40
41
  if (dataStr === "[DONE]") break;
41
42
 
42
43
  try {
@@ -127,6 +127,11 @@ function parseRequestPath(req: Request, url: URL): ParsedRequestPath {
127
127
  }
128
128
 
129
129
  function buildUpstreamUrl(upstreamBase: string, normalizedPath: string): string {
130
+ // Handle case where upstreamBase already ends with /v1 and normalizedPath starts with /v1/
131
+ // to avoid double /v1/v1/ duplication
132
+ if (upstreamBase.endsWith("/v1") && normalizedPath.startsWith("/v1/")) {
133
+ return upstreamBase + normalizedPath.slice(3); // Remove leading /v1 from path
134
+ }
130
135
  return upstreamBase + normalizedPath;
131
136
  }
132
137