@tonyclaw/llm-inspector 1.7.8 → 1.7.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.
@@ -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";
@@ -134,6 +135,17 @@ export function ProvidersPanel({
134
135
 
135
136
  const runTest = useCallback(
136
137
  async (providerId: string): Promise<void> => {
138
+ // Clear previous test results when starting a new test
139
+ const resetResults: TestResults = {
140
+ anthropic: { nonStreaming: { notConfigured: true }, streaming: { notConfigured: true } },
141
+ openai: { nonStreaming: { notConfigured: true }, streaming: { notConfigured: true } },
142
+ };
143
+ if (onTestResultsChange) {
144
+ onTestResultsChange(providerId, resetResults);
145
+ } else {
146
+ setInternalTestResults((prev) => ({ ...prev, [providerId]: resetResults }));
147
+ }
148
+
137
149
  // Use callback form if available, otherwise direct set
138
150
  if (onTestingProvidersChange) {
139
151
  onTestingProvidersChange(providerId, true);
@@ -196,7 +208,8 @@ export function ProvidersPanel({
196
208
  }
197
209
  } catch (err) {
198
210
  // Check if this was an abort (timeout)
199
- if (err instanceof Error && err.name === "AbortError") {
211
+ const isAbort = err instanceof Error && err.name === "AbortError";
212
+ if (isAbort) {
200
213
  const timeoutResult: TestResults = {
201
214
  anthropic: {
202
215
  nonStreaming: {
@@ -219,17 +232,25 @@ export function ProvidersPanel({
219
232
  setInternalTestResults((prev) => ({ ...prev, [providerId]: timeoutResult }));
220
233
  }
221
234
  }
235
+ // If it's not an abort error, the test results won't be updated
236
+ // which means the previous results will persist (or it will show "Not configured" reset state)
222
237
  } finally {
238
+ // Always clear the countdown and testing state, even if an error occurs
223
239
  clearInterval(intervalId);
224
240
  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
- });
241
+ // Ensure testingProviders state is cleared - wrap in try to guarantee execution
242
+ try {
243
+ if (onTestingProvidersChange) {
244
+ onTestingProvidersChange(providerId, false);
245
+ } else {
246
+ setInternalTestingProviders((prev) => {
247
+ const next = new Set(prev);
248
+ next.delete(providerId);
249
+ return next;
250
+ });
251
+ }
252
+ } catch {
253
+ // Ignore errors in state updates to ensure we always clean up
233
254
  }
234
255
  }
235
256
  },
@@ -328,6 +349,74 @@ export function ProvidersPanel({
328
349
  })();
329
350
  }
330
351
 
352
+ const fileInputRef = useRef<HTMLInputElement>(null);
353
+
354
+ function handleExport(includeKeys: boolean): void {
355
+ const url = `/api/providers/export${includeKeys ? "?includeKeys=true" : ""}`;
356
+ void (async () => {
357
+ try {
358
+ const res = await fetch(url);
359
+ if (!res.ok) {
360
+ setError("Failed to export providers");
361
+ return;
362
+ }
363
+ const blob = await res.blob();
364
+ const downloadUrl = URL.createObjectURL(blob);
365
+ const a = document.createElement("a");
366
+ a.href = downloadUrl;
367
+ a.download =
368
+ res.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "providers.json";
369
+ document.body.appendChild(a);
370
+ a.click();
371
+ document.body.removeChild(a);
372
+ URL.revokeObjectURL(downloadUrl);
373
+ } catch {
374
+ setError("Failed to export providers");
375
+ }
376
+ })();
377
+ }
378
+
379
+ function handleImportClick(): void {
380
+ fileInputRef.current?.click();
381
+ }
382
+
383
+ function handleFileChange(e: React.ChangeEvent<HTMLInputElement>): void {
384
+ const file = e.target.files?.[0];
385
+ if (!file) return;
386
+
387
+ void (async () => {
388
+ try {
389
+ const text = await file.text();
390
+ const res = await fetch("/api/providers/import", {
391
+ method: "POST",
392
+ headers: { "Content-Type": "application/json" },
393
+ body: JSON.stringify(text),
394
+ });
395
+ const ImportResponseSchema = z.object({
396
+ success: z.boolean().optional(),
397
+ imported: z.number().optional(),
398
+ message: z.string().optional(),
399
+ errors: z.array(z.string()).optional(),
400
+ });
401
+ const data = ImportResponseSchema.parse(await res.json());
402
+ if (res.ok && data.imported !== undefined && data.imported > 0) {
403
+ await fetchProviders();
404
+ // Show success message via error state temporarily
405
+ setError(null);
406
+ // Use a ref or state to show success - for now just refresh list
407
+ } else if (data.errors && data.errors.length > 0) {
408
+ setError(data.errors.join("; "));
409
+ } else {
410
+ setError(data.message ?? "Import failed");
411
+ }
412
+ } catch {
413
+ setError("Failed to import providers");
414
+ }
415
+ // Reset file input
416
+ e.target.value = "";
417
+ })();
418
+ }
419
+
331
420
  // Only show loading if we have no providers at all (prevents flashing when reopening Settings during test)
332
421
  if (isLoading && providers.length === 0) {
333
422
  return (
@@ -361,10 +450,27 @@ export function ProvidersPanel({
361
450
  <div className="space-y-4">
362
451
  <div className="flex items-center justify-between">
363
452
  <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>
453
+ <div className="flex items-center gap-2">
454
+ <Button variant="outline" size="sm" onClick={() => handleExport(false)} className="gap-1">
455
+ <Download className="size-3" />
456
+ Export
457
+ </Button>
458
+ <Button variant="outline" size="sm" onClick={handleImportClick} className="gap-1">
459
+ <Upload className="size-3" />
460
+ Import
461
+ </Button>
462
+ <input
463
+ type="file"
464
+ ref={fileInputRef}
465
+ accept=".json"
466
+ onChange={handleFileChange}
467
+ style={{ display: "none" }}
468
+ />
469
+ <Button onClick={() => setShowForm(true)} size="sm" className="gap-1">
470
+ <Plus className="size-4" />
471
+ Add Provider
472
+ </Button>
473
+ </div>
368
474
  </div>
369
475
 
370
476
  {configPath !== null && (
@@ -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
+ });
@@ -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
 
@@ -202,6 +202,104 @@ export function deleteProvider(id: string): boolean {
202
202
  return true;
203
203
  }
204
204
 
205
+ /**
206
+ * Export all providers as a JSON string (without API keys for security).
207
+ */
208
+ export function exportProviders(): string {
209
+ const providers = getProviders();
210
+ // Mask API keys for security
211
+ const safeProviders = providers.map(({ apiKey, ...rest }) => ({
212
+ ...rest,
213
+ apiKey: maskApiKey(apiKey),
214
+ }));
215
+ return JSON.stringify(safeProviders, null, 2);
216
+ }
217
+
218
+ /**
219
+ * Export all providers as a JSON string including API keys (user must opt-in).
220
+ */
221
+ export function exportProvidersWithKeys(): string {
222
+ return JSON.stringify(getProviders(), null, 2);
223
+ }
224
+
225
+ /**
226
+ * Import providers from a JSON string.
227
+ * Returns { imported: number, errors: string[] }
228
+ */
229
+ export function importProviders(json: string): { imported: number; errors: string[] } {
230
+ const errors: string[] = [];
231
+ let imported = 0;
232
+
233
+ try {
234
+ const parsed: unknown = JSON.parse(json);
235
+
236
+ // Use Zod to validate the structure
237
+ const ImportSchema = z.union([
238
+ z.array(ProviderConfigSchema),
239
+ z.object({
240
+ providers: z.array(ProviderConfigSchema),
241
+ }),
242
+ ]);
243
+
244
+ const parseResult = ImportSchema.safeParse(parsed);
245
+ if (!parseResult.success) {
246
+ return {
247
+ imported: 0,
248
+ errors: [`Invalid format: ${parseResult.error.message}`],
249
+ };
250
+ }
251
+
252
+ const providerArray = Array.isArray(parseResult.data)
253
+ ? parseResult.data
254
+ : parseResult.data.providers;
255
+
256
+ for (let i = 0; i < providerArray.length; i++) {
257
+ const item = providerArray[i];
258
+ const result = ProviderConfigSchema.safeParse(item);
259
+
260
+ if (!result.success) {
261
+ errors.push(`Provider ${i + 1}: ${result.error.message}`);
262
+ continue;
263
+ }
264
+
265
+ const provider = result.data;
266
+ const existing = getProviders().find(
267
+ (p) => p.name === provider.name && p.anthropicBaseUrl === provider.anthropicBaseUrl,
268
+ );
269
+
270
+ if (existing) {
271
+ errors.push(`Provider "${provider.name}" already exists, skipping`);
272
+ continue;
273
+ }
274
+
275
+ // Generate new ID and add
276
+ const newProvider: ProviderConfig = {
277
+ ...provider,
278
+ id: randomUUID(),
279
+ createdAt: new Date().toISOString(),
280
+ updatedAt: new Date().toISOString(),
281
+ };
282
+
283
+ const providers = getProviders();
284
+ providers.push(newProvider);
285
+ store.set("providers", providers);
286
+ imported++;
287
+ }
288
+ } catch (err) {
289
+ return {
290
+ imported: 0,
291
+ errors: [`Failed to parse JSON: ${err instanceof Error ? err.message : String(err)}`],
292
+ };
293
+ }
294
+
295
+ return { imported, errors };
296
+ }
297
+
298
+ function maskApiKey(apiKey: string): string {
299
+ if (apiKey.length <= 8) return "••••••••";
300
+ return apiKey.slice(0, 4) + "••••••••" + apiKey.slice(-4);
301
+ }
302
+
205
303
  /**
206
304
  * Converts display model name to API usage name based on provider-specific rules.
207
305
  * Default behavior: returns model as-is.
@@ -0,0 +1,26 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { exportProviders, exportProvidersWithKeys } from "../../proxy/providers";
3
+
4
+ export const Route = createFileRoute("/api/providers/export")({
5
+ server: {
6
+ handlers: {
7
+ GET: ({ request }: { request: Request }) => {
8
+ const url = new URL(request.url);
9
+ const includeKeys = url.searchParams.get("includeKeys") === "true";
10
+
11
+ const json = includeKeys ? exportProvidersWithKeys() : exportProviders();
12
+
13
+ const filename = includeKeys
14
+ ? `llm-inspector-providers-${Date.now()}.json`
15
+ : `llm-inspector-providers-safe-${Date.now()}.json`;
16
+
17
+ return new Response(json, {
18
+ headers: {
19
+ "Content-Type": "application/json",
20
+ "Content-Disposition": `attachment; filename="${filename}"`,
21
+ },
22
+ });
23
+ },
24
+ },
25
+ },
26
+ });
@@ -0,0 +1,47 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { z } from "zod";
3
+ import { importProviders } from "../../proxy/providers";
4
+
5
+ const ImportRequestSchema = z.union([z.string(), z.object({ providers: z.unknown() })]);
6
+
7
+ export const Route = createFileRoute("/api/providers/import")({
8
+ server: {
9
+ handlers: {
10
+ POST: async ({ request }: { request: Request }) => {
11
+ try {
12
+ const rawBody = await request.text();
13
+ let jsonContent: string;
14
+ try {
15
+ const parsedBody: unknown = JSON.parse(rawBody);
16
+ jsonContent = typeof parsedBody === "string" ? parsedBody : JSON.stringify(parsedBody);
17
+ } catch {
18
+ jsonContent = rawBody;
19
+ }
20
+
21
+ if (!jsonContent || jsonContent.trim() === "") {
22
+ return Response.json({ error: "No JSON content provided" }, { status: 400 });
23
+ }
24
+
25
+ const result = importProviders(jsonContent);
26
+
27
+ return Response.json({
28
+ success: result.imported > 0,
29
+ imported: result.imported,
30
+ errors: result.errors,
31
+ message:
32
+ result.imported > 0
33
+ ? `Successfully imported ${result.imported} provider(s)`
34
+ : result.errors.length > 0
35
+ ? `Import completed with ${result.errors.length} error(s)`
36
+ : "No providers imported",
37
+ });
38
+ } catch (err) {
39
+ return Response.json(
40
+ { error: `Failed to import: ${err instanceof Error ? err.message : String(err)}` },
41
+ { status: 400 },
42
+ );
43
+ }
44
+ },
45
+ },
46
+ },
47
+ });