@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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/index-BLVa7n9b.css +1 -0
- package/.output/public/assets/index-DH3FOgcK.js +97 -0
- package/.output/public/assets/main-Beo3LJDa.js +17 -0
- package/.output/server/_libs/dequal.mjs +27 -0
- package/.output/server/_libs/lucide-react.mjs +98 -91
- package/.output/server/_libs/swr.mjs +938 -0
- package/.output/server/_libs/use-sync-external-store.mjs +64 -1
- package/.output/server/_libs/zod.mjs +1 -0
- package/.output/server/_ssr/{index-hNquJMfH.mjs → index-HkueJ4Un.mjs} +218 -71
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-MmnX-LYh.mjs → router-DTswxb7l.mjs} +264 -52
- package/.output/server/_tanstack-start-manifest_v-DhUuivt-.mjs +4 -0
- package/.output/server/index.mjs +26 -26
- package/package.json +2 -1
- package/src/components/ProxyViewer.tsx +2 -0
- package/src/components/providers/ProviderCard.tsx +38 -33
- package/src/components/providers/ProviderLogo.tsx +6 -1
- package/src/components/providers/ProvidersPanel.tsx +144 -43
- package/src/components/providers/SettingsDialog.tsx +5 -3
- package/src/components/proxy-viewer/ConversationGroup.tsx +3 -3
- package/src/components/proxy-viewer/LogEntry.tsx +6 -3
- package/src/components/proxy-viewer/LogEntryHeader.tsx +3 -2
- package/src/lib/useProviders.ts +30 -0
- package/src/proxy/formats/anthropic/stream.ts +3 -2
- package/src/proxy/formats/openai/stream.ts +3 -2
- package/src/proxy/handler.ts +5 -0
- package/src/proxy/providers.ts +98 -0
- package/src/routes/__root.tsx +4 -1
- package/src/routes/api/providers.export.ts +26 -0
- package/src/routes/api/providers.import.ts +47 -0
- package/.output/public/assets/index-B3RwBPLW.css +0 -1
- package/.output/public/assets/index-C8o6bEv6.js +0 -97
- package/.output/public/assets/main-Bxc5pKCu.js +0 -17
- 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
|
-
<
|
|
116
|
-
<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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
<
|
|
365
|
-
<
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
66
|
+
const trimmedLine = line.trim();
|
|
67
|
+
if (!trimmedLine.startsWith("data: ")) continue;
|
|
67
68
|
try {
|
|
68
|
-
const json: unknown = JSON.parse(
|
|
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
|
-
|
|
39
|
-
|
|
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 {
|
package/src/proxy/handler.ts
CHANGED
|
@@ -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
|
|