@tonyclaw/llm-inspector 1.7.7 → 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);
@@ -141,6 +153,9 @@ export function ProvidersPanel({
141
153
  setInternalTestingProviders((prev) => new Set(prev).add(providerId));
142
154
  }
143
155
 
156
+ // Create abort controller for this test request
157
+ const controller = new AbortController();
158
+
144
159
  // Start countdown
145
160
  let remaining = TEST_TIMEOUT_SECONDS;
146
161
  setTestingTimeLeft(providerId, remaining);
@@ -149,11 +164,16 @@ export function ProvidersPanel({
149
164
  setTestingTimeLeft(providerId, remaining);
150
165
  if (remaining <= 0) {
151
166
  clearInterval(intervalId);
167
+ // Abort the fetch request when time runs out
168
+ controller.abort();
152
169
  }
153
170
  }, 1000);
154
171
 
155
172
  try {
156
- const res = await fetch(`/api/providers/${providerId}/test`, { method: "POST" });
173
+ const res = await fetch(`/api/providers/${providerId}/test`, {
174
+ method: "POST",
175
+ signal: controller.signal,
176
+ });
157
177
  if (res.ok) {
158
178
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
159
179
  const results = (await res.json()) as TestResults;
@@ -162,18 +182,75 @@ export function ProvidersPanel({
162
182
  } else {
163
183
  setInternalTestResults((prev) => ({ ...prev, [providerId]: results }));
164
184
  }
185
+ } else {
186
+ // Non-ok response, create error result
187
+ const errorResult: TestResults = {
188
+ anthropic: {
189
+ nonStreaming: {
190
+ success: false,
191
+ error: { message: `HTTP ${res.status}: ${res.statusText}`, type: "server_error" },
192
+ },
193
+ streaming: { notConfigured: true },
194
+ },
195
+ openai: {
196
+ nonStreaming: {
197
+ success: false,
198
+ error: { message: `HTTP ${res.status}: ${res.statusText}`, type: "server_error" },
199
+ },
200
+ streaming: { notConfigured: true },
201
+ },
202
+ };
203
+ if (onTestResultsChange) {
204
+ onTestResultsChange(providerId, errorResult);
205
+ } else {
206
+ setInternalTestResults((prev) => ({ ...prev, [providerId]: errorResult }));
207
+ }
208
+ }
209
+ } catch (err) {
210
+ // Check if this was an abort (timeout)
211
+ const isAbort = err instanceof Error && err.name === "AbortError";
212
+ if (isAbort) {
213
+ const timeoutResult: TestResults = {
214
+ anthropic: {
215
+ nonStreaming: {
216
+ success: false,
217
+ error: { message: "Request timed out", type: "timeout" },
218
+ },
219
+ streaming: { notConfigured: true },
220
+ },
221
+ openai: {
222
+ nonStreaming: {
223
+ success: false,
224
+ error: { message: "Request timed out", type: "timeout" },
225
+ },
226
+ streaming: { notConfigured: true },
227
+ },
228
+ };
229
+ if (onTestResultsChange) {
230
+ onTestResultsChange(providerId, timeoutResult);
231
+ } else {
232
+ setInternalTestResults((prev) => ({ ...prev, [providerId]: timeoutResult }));
233
+ }
165
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)
166
237
  } finally {
238
+ // Always clear the countdown and testing state, even if an error occurs
167
239
  clearInterval(intervalId);
168
240
  setTestingTimeLeft(providerId, undefined);
169
- if (onTestingProvidersChange) {
170
- onTestingProvidersChange(providerId, false);
171
- } else {
172
- setInternalTestingProviders((prev) => {
173
- const next = new Set(prev);
174
- next.delete(providerId);
175
- return next;
176
- });
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
177
254
  }
178
255
  }
179
256
  },
@@ -272,6 +349,74 @@ export function ProvidersPanel({
272
349
  })();
273
350
  }
274
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
+
275
420
  // Only show loading if we have no providers at all (prevents flashing when reopening Settings during test)
276
421
  if (isLoading && providers.length === 0) {
277
422
  return (
@@ -305,10 +450,27 @@ export function ProvidersPanel({
305
450
  <div className="space-y-4">
306
451
  <div className="flex items-center justify-between">
307
452
  <h3 className="text-lg font-medium">Providers</h3>
308
- <Button onClick={() => setShowForm(true)} size="sm" className="gap-1">
309
- <Plus className="size-4" />
310
- Add Provider
311
- </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>
312
474
  </div>
313
475
 
314
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
+ });