@tonyclaw/llm-inspector 1.7.9 → 1.9.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-DdJSLfxK.css +1 -0
- package/.output/public/assets/index-DyKLPMPn.js +97 -0
- package/.output/public/assets/main-Cu0oTDfX.js +17 -0
- package/.output/server/_libs/dequal.mjs +27 -0
- package/.output/server/_libs/swr.mjs +938 -0
- package/.output/server/_libs/use-sync-external-store.mjs +64 -1
- package/.output/server/_ssr/{index-CAIDMqNv.mjs → index-COIATcfa.mjs} +186 -88
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-CsCLdrXq.mjs → router-CwmgKXBJ.mjs} +259 -74
- package/.output/server/{_tanstack-start-manifest_v-BF6ge6dS.mjs → _tanstack-start-manifest_v-C7hQOzvX.mjs} +1 -1
- package/.output/server/index.mjs +23 -23
- package/README.md +8 -209
- package/package.json +2 -1
- package/src/components/ProxyViewer.tsx +2 -0
- package/src/components/ProxyViewerContainer.tsx +10 -1
- package/src/components/providers/ProviderCard.tsx +57 -48
- package/src/components/providers/ProviderForm.tsx +21 -0
- package/src/components/providers/ProviderLogo.tsx +6 -1
- package/src/components/providers/ProvidersPanel.tsx +29 -34
- package/src/components/providers/SettingsDialog.tsx +5 -3
- package/src/components/proxy-viewer/LogEntry.tsx +7 -0
- package/src/components/proxy-viewer/ResponseView.tsx +32 -6
- package/src/lib/useProviders.ts +30 -0
- package/src/proxy/chunkStorage.ts +4 -6
- package/src/proxy/formats/anthropic/schemas.ts +9 -0
- package/src/proxy/formats/anthropic/stream.ts +11 -0
- package/src/proxy/formats/openai/stream.ts +15 -0
- package/src/proxy/handler.ts +34 -27
- package/src/proxy/logIndex.ts +52 -7
- package/src/proxy/logger.ts +60 -10
- package/src/proxy/providers.ts +5 -0
- package/src/proxy/schemas.ts +2 -0
- package/src/proxy/socketTracker.ts +90 -36
- package/src/proxy/store.ts +24 -14
- package/src/routes/__root.tsx +4 -1
- package/src/routes/api/providers.$providerId.ts +1 -0
- package/src/routes/api/providers.ts +2 -0
- package/.output/public/assets/index-B3RwBPLW.css +0 -1
- package/.output/public/assets/index-CB8ZIeEk.js +0 -97
- package/.output/public/assets/main-BrU8NdGQ.js +0 -17
|
@@ -32,9 +32,11 @@ type TestResult = {
|
|
|
32
32
|
|
|
33
33
|
type NotConfigured = { notConfigured: true };
|
|
34
34
|
|
|
35
|
+
type Testing = { testing: true };
|
|
36
|
+
|
|
35
37
|
type StreamingTestResults = {
|
|
36
|
-
nonStreaming: TestResult | NotConfigured;
|
|
37
|
-
streaming: TestResult | NotConfigured;
|
|
38
|
+
nonStreaming: TestResult | NotConfigured | Testing;
|
|
39
|
+
streaming: TestResult | NotConfigured | Testing;
|
|
38
40
|
};
|
|
39
41
|
|
|
40
42
|
export type TestResults = {
|
|
@@ -47,7 +49,7 @@ type ProvidersPanelProps = {
|
|
|
47
49
|
externalTestResults?: Record<string, TestResults>;
|
|
48
50
|
externalTestingProviders?: Set<string>;
|
|
49
51
|
externalTestingTimeLeft?: Record<string, number>;
|
|
50
|
-
|
|
52
|
+
onProvidersMutate?: () => void;
|
|
51
53
|
onTestResultsChange?: (providerId: string, results: TestResults) => void;
|
|
52
54
|
onTestingProvidersChange?: (providerId: string, isTesting: boolean) => void;
|
|
53
55
|
onTestingTimeLeftChange?: (providerId: string, seconds: number | undefined) => void;
|
|
@@ -58,13 +60,11 @@ export function ProvidersPanel({
|
|
|
58
60
|
externalTestResults,
|
|
59
61
|
externalTestingProviders,
|
|
60
62
|
externalTestingTimeLeft,
|
|
61
|
-
|
|
63
|
+
onProvidersMutate,
|
|
62
64
|
onTestResultsChange,
|
|
63
65
|
onTestingProvidersChange,
|
|
64
66
|
onTestingTimeLeftChange,
|
|
65
67
|
}: ProvidersPanelProps): JSX.Element {
|
|
66
|
-
const [internalProviders, setInternalProviders] = useState<ProviderConfig[]>([]);
|
|
67
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
68
68
|
const [showForm, setShowForm] = useState(false);
|
|
69
69
|
const [editingProvider, setEditingProvider] = useState<ProviderConfig | undefined>();
|
|
70
70
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -76,9 +76,8 @@ export function ProvidersPanel({
|
|
|
76
76
|
const [configPath, setConfigPath] = useState<string | null>(null);
|
|
77
77
|
const [configPathCopied, setConfigPathCopied] = useState(false);
|
|
78
78
|
|
|
79
|
-
// Use external state if provided, otherwise use internal state
|
|
80
|
-
const providers = externalProviders ??
|
|
81
|
-
const setProviders = onProvidersChange ?? setInternalProviders;
|
|
79
|
+
// Use external state if provided (from SWR), otherwise use internal state
|
|
80
|
+
const providers = externalProviders ?? [];
|
|
82
81
|
const testResults = externalTestResults ?? internalTestResults;
|
|
83
82
|
const setTestResults = onTestResultsChange
|
|
84
83
|
? (id: string, results: TestResults) => onTestResultsChange(id, results)
|
|
@@ -102,21 +101,7 @@ export function ProvidersPanel({
|
|
|
102
101
|
}
|
|
103
102
|
};
|
|
104
103
|
|
|
105
|
-
const fetchProviders = useCallback(async (): Promise<void> => {
|
|
106
|
-
try {
|
|
107
|
-
const providersRes = await fetch("/api/providers");
|
|
108
|
-
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
109
|
-
const providersData = (await providersRes.json()) as ProviderConfig[];
|
|
110
|
-
setProviders(providersData);
|
|
111
|
-
} catch {
|
|
112
|
-
setError("Failed to load providers");
|
|
113
|
-
} finally {
|
|
114
|
-
setIsLoading(false);
|
|
115
|
-
}
|
|
116
|
-
}, []);
|
|
117
|
-
|
|
118
104
|
useEffect(() => {
|
|
119
|
-
void fetchProviders();
|
|
120
105
|
void (async () => {
|
|
121
106
|
try {
|
|
122
107
|
const res = await fetch("/api/config/paths");
|
|
@@ -129,7 +114,7 @@ export function ProvidersPanel({
|
|
|
129
114
|
// Ignore
|
|
130
115
|
}
|
|
131
116
|
})();
|
|
132
|
-
}, [
|
|
117
|
+
}, []);
|
|
133
118
|
|
|
134
119
|
const TEST_TIMEOUT_SECONDS = 30;
|
|
135
120
|
|
|
@@ -137,8 +122,8 @@ export function ProvidersPanel({
|
|
|
137
122
|
async (providerId: string): Promise<void> => {
|
|
138
123
|
// Clear previous test results when starting a new test
|
|
139
124
|
const resetResults: TestResults = {
|
|
140
|
-
anthropic: { nonStreaming: {
|
|
141
|
-
openai: { nonStreaming: {
|
|
125
|
+
anthropic: { nonStreaming: { testing: true }, streaming: { testing: true } },
|
|
126
|
+
openai: { nonStreaming: { testing: true }, streaming: { testing: true } },
|
|
142
127
|
};
|
|
143
128
|
if (onTestResultsChange) {
|
|
144
129
|
onTestResultsChange(providerId, resetResults);
|
|
@@ -287,7 +272,7 @@ export function ProvidersPanel({
|
|
|
287
272
|
}
|
|
288
273
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
289
274
|
const newProvider = (await res.json()) as ProviderConfig;
|
|
290
|
-
|
|
275
|
+
onProvidersMutate?.();
|
|
291
276
|
setShowForm(false);
|
|
292
277
|
// Run test on new provider
|
|
293
278
|
await runTest(newProvider.id);
|
|
@@ -325,7 +310,7 @@ export function ProvidersPanel({
|
|
|
325
310
|
}
|
|
326
311
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
327
312
|
const updated = (await res.json()) as ProviderConfig;
|
|
328
|
-
|
|
313
|
+
onProvidersMutate?.();
|
|
329
314
|
setEditingProvider(undefined);
|
|
330
315
|
// Run test on updated provider
|
|
331
316
|
await runTest(updated.id);
|
|
@@ -345,7 +330,7 @@ export function ProvidersPanel({
|
|
|
345
330
|
setError(err.error ?? "Failed to delete provider");
|
|
346
331
|
return;
|
|
347
332
|
}
|
|
348
|
-
|
|
333
|
+
onProvidersMutate?.();
|
|
349
334
|
})();
|
|
350
335
|
}
|
|
351
336
|
|
|
@@ -400,7 +385,7 @@ export function ProvidersPanel({
|
|
|
400
385
|
});
|
|
401
386
|
const data = ImportResponseSchema.parse(await res.json());
|
|
402
387
|
if (res.ok && data.imported !== undefined && data.imported > 0) {
|
|
403
|
-
|
|
388
|
+
onProvidersMutate?.();
|
|
404
389
|
// Show success message via error state temporarily
|
|
405
390
|
setError(null);
|
|
406
391
|
// Use a ref or state to show success - for now just refresh list
|
|
@@ -418,7 +403,7 @@ export function ProvidersPanel({
|
|
|
418
403
|
}
|
|
419
404
|
|
|
420
405
|
// Only show loading if we have no providers at all (prevents flashing when reopening Settings during test)
|
|
421
|
-
if (
|
|
406
|
+
if (providers.length === 0) {
|
|
422
407
|
return (
|
|
423
408
|
<div className="flex items-center justify-center py-8">
|
|
424
409
|
<p className="text-sm text-muted-foreground">Loading providers...</p>
|
|
@@ -448,14 +433,24 @@ export function ProvidersPanel({
|
|
|
448
433
|
|
|
449
434
|
return (
|
|
450
435
|
<div className="space-y-4">
|
|
451
|
-
<div className="flex items-center justify-between">
|
|
436
|
+
<div className="flex items-center justify-between sticky top-0 z-10 bg-background pb-2">
|
|
452
437
|
<h3 className="text-lg font-medium">Providers</h3>
|
|
453
438
|
<div className="flex items-center gap-2">
|
|
454
|
-
<Button
|
|
439
|
+
<Button
|
|
440
|
+
variant="outline"
|
|
441
|
+
size="sm"
|
|
442
|
+
onClick={() => handleExport(false)}
|
|
443
|
+
className="gap-1 hover:bg-muted"
|
|
444
|
+
>
|
|
455
445
|
<Download className="size-3" />
|
|
456
446
|
Export
|
|
457
447
|
</Button>
|
|
458
|
-
<Button
|
|
448
|
+
<Button
|
|
449
|
+
variant="outline"
|
|
450
|
+
size="sm"
|
|
451
|
+
onClick={handleImportClick}
|
|
452
|
+
className="gap-1 hover:bg-muted"
|
|
453
|
+
>
|
|
459
454
|
<Upload className="size-3" />
|
|
460
455
|
Import
|
|
461
456
|
</Button>
|
|
@@ -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}
|
|
@@ -182,6 +182,12 @@ export const LogEntry = memo(function LogEntry({
|
|
|
182
182
|
|
|
183
183
|
<TabsContent value="raw">
|
|
184
184
|
<div className="px-4 py-3 space-y-3">
|
|
185
|
+
{log.error !== undefined && log.error !== null && (
|
|
186
|
+
<div className="rounded border border-destructive/50 bg-destructive/10 p-3 text-xs">
|
|
187
|
+
<div className="font-semibold text-destructive mb-1">SSE Error</div>
|
|
188
|
+
<div className="text-muted-foreground font-mono">{log.error}</div>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
185
191
|
<div className="flex justify-end">
|
|
186
192
|
<CopyButton
|
|
187
193
|
text={log.responseText}
|
|
@@ -215,6 +221,7 @@ export const LogEntry = memo(function LogEntry({
|
|
|
215
221
|
cacheCreationInputTokens={log.cacheCreationInputTokens}
|
|
216
222
|
cacheReadInputTokens={log.cacheReadInputTokens}
|
|
217
223
|
apiFormat={log.apiFormat}
|
|
224
|
+
error={log.error}
|
|
218
225
|
/>
|
|
219
226
|
</div>
|
|
220
227
|
</TabsContent>
|
|
@@ -19,6 +19,8 @@ export type ResponseViewProps = {
|
|
|
19
19
|
cacheCreationInputTokens?: number | null;
|
|
20
20
|
cacheReadInputTokens?: number | null;
|
|
21
21
|
apiFormat?: "anthropic" | "openai" | "unknown";
|
|
22
|
+
/** SSE error message from streaming response */
|
|
23
|
+
error?: string | null;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
function getStatusClasses(category: StatusCategory): string {
|
|
@@ -95,8 +97,9 @@ export function ResponseView({
|
|
|
95
97
|
cacheCreationInputTokens,
|
|
96
98
|
cacheReadInputTokens,
|
|
97
99
|
apiFormat,
|
|
100
|
+
error,
|
|
98
101
|
}: ResponseViewProps): JSX.Element {
|
|
99
|
-
if (responseText === null) {
|
|
102
|
+
if (responseText === null && error === undefined) {
|
|
100
103
|
return (
|
|
101
104
|
<div className="flex items-center gap-2 py-3">
|
|
102
105
|
<StatusIndicator status={responseStatus} />
|
|
@@ -105,18 +108,41 @@ export function ResponseView({
|
|
|
105
108
|
);
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
const
|
|
111
|
+
const isHttpError = responseStatus !== null && responseStatus >= 400;
|
|
109
112
|
|
|
110
|
-
if (
|
|
113
|
+
if (isHttpError) {
|
|
111
114
|
return (
|
|
112
115
|
<div className="space-y-2">
|
|
113
116
|
<StatusIndicator status={responseStatus} />
|
|
114
|
-
<ErrorResponseView text={responseText} />
|
|
117
|
+
<ErrorResponseView text={responseText ?? ""} />
|
|
118
|
+
{error !== undefined && error !== null && (
|
|
119
|
+
<div className="rounded border border-destructive/50 bg-destructive/10 p-3 text-xs">
|
|
120
|
+
<div className="font-semibold text-destructive mb-1">SSE Error</div>
|
|
121
|
+
<div className="text-muted-foreground font-mono">{error}</div>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (error !== undefined && error !== null) {
|
|
129
|
+
return (
|
|
130
|
+
<div className="space-y-2">
|
|
131
|
+
<StatusIndicator status={responseStatus} />
|
|
132
|
+
<div className="rounded border border-destructive/50 bg-destructive/10 p-3 text-xs">
|
|
133
|
+
<div className="font-semibold text-destructive mb-1">SSE Error</div>
|
|
134
|
+
<div className="text-muted-foreground font-mono">{error}</div>
|
|
135
|
+
</div>
|
|
136
|
+
{responseText !== null && (
|
|
137
|
+
<div className="mt-2">
|
|
138
|
+
<ErrorResponseView text={responseText} />
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
115
141
|
</div>
|
|
116
142
|
);
|
|
117
143
|
}
|
|
118
144
|
|
|
119
|
-
const parsed = parseResponse(responseText, apiFormat);
|
|
145
|
+
const parsed = responseText !== null ? parseResponse(responseText, apiFormat) : null;
|
|
120
146
|
|
|
121
147
|
if (parsed !== null) {
|
|
122
148
|
return (
|
|
@@ -155,7 +181,7 @@ export function ResponseView({
|
|
|
155
181
|
</span>
|
|
156
182
|
)}
|
|
157
183
|
</div>
|
|
158
|
-
<MarkdownFallbackView text={responseText} />
|
|
184
|
+
<MarkdownFallbackView text={responseText ?? ""} />
|
|
159
185
|
</div>
|
|
160
186
|
);
|
|
161
187
|
}
|
|
@@ -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
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
copyFileSync,
|
|
9
9
|
} from "node:fs";
|
|
10
10
|
import { join, isAbsolute } from "node:path";
|
|
11
|
+
import { logger } from "./logger";
|
|
11
12
|
import { z } from "zod";
|
|
12
13
|
import { JsonValueSchema, type StreamingChunk } from "./schemas";
|
|
13
14
|
|
|
@@ -63,8 +64,7 @@ export function writeChunks(logId: number, chunks: StreamingChunk[], truncated?:
|
|
|
63
64
|
try {
|
|
64
65
|
mkdirSync(dir, { recursive: true });
|
|
65
66
|
} catch (err) {
|
|
66
|
-
|
|
67
|
-
console.error("[chunkStorage] Failed to create chunks directory:", err);
|
|
67
|
+
logger.error("[chunkStorage] Failed to create chunks directory:", String(err));
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const data: StreamingChunksData = { chunks, truncated };
|
|
@@ -73,8 +73,7 @@ export function writeChunks(logId: number, chunks: StreamingChunk[], truncated?:
|
|
|
73
73
|
try {
|
|
74
74
|
writeFileSync(tempPath, JSON.stringify(data), "utf-8");
|
|
75
75
|
} catch (err) {
|
|
76
|
-
|
|
77
|
-
console.error("[chunkStorage] Failed to write chunks temp file:", err);
|
|
76
|
+
logger.error("[chunkStorage] Failed to write chunks temp file:", String(err));
|
|
78
77
|
return targetPath;
|
|
79
78
|
}
|
|
80
79
|
|
|
@@ -89,8 +88,7 @@ export function writeChunks(logId: number, chunks: StreamingChunk[], truncated?:
|
|
|
89
88
|
copyFileSync(tempPath, targetPath);
|
|
90
89
|
unlinkSync(tempPath);
|
|
91
90
|
} catch (copyErr) {
|
|
92
|
-
|
|
93
|
-
console.error("[chunkStorage] Failed to copy chunks file:", copyErr);
|
|
91
|
+
logger.error("[chunkStorage] Failed to copy chunks file:", String(copyErr));
|
|
94
92
|
}
|
|
95
93
|
}
|
|
96
94
|
|
|
@@ -196,6 +196,14 @@ const SsePingEvent = z.object({
|
|
|
196
196
|
type: z.literal("ping"),
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
const SseErrorEvent = z.object({
|
|
200
|
+
type: z.literal("error"),
|
|
201
|
+
error: z.object({
|
|
202
|
+
type: z.string(),
|
|
203
|
+
message: z.string(),
|
|
204
|
+
}),
|
|
205
|
+
});
|
|
206
|
+
|
|
199
207
|
export const SseEventSchema = z.discriminatedUnion("type", [
|
|
200
208
|
SseMessageStartEvent,
|
|
201
209
|
SseContentBlockStartEvent,
|
|
@@ -204,6 +212,7 @@ export const SseEventSchema = z.discriminatedUnion("type", [
|
|
|
204
212
|
SseMessageDeltaEvent,
|
|
205
213
|
SseMessageStopEvent,
|
|
206
214
|
SsePingEvent,
|
|
215
|
+
SseErrorEvent,
|
|
207
216
|
]);
|
|
208
217
|
|
|
209
218
|
export type AnthropicRequest = z.infer<typeof AnthropicRequestSchema>;
|
|
@@ -133,6 +133,17 @@ export function extractAnthropicStream(
|
|
|
133
133
|
stopSequence = data.delta.stop_sequence ?? null;
|
|
134
134
|
outputTokens = data.usage.output_tokens;
|
|
135
135
|
log.outputTokens = outputTokens;
|
|
136
|
+
// Cache tokens may also be present in message_delta usage
|
|
137
|
+
if (data.usage.cache_creation_input_tokens !== undefined) {
|
|
138
|
+
log.cacheCreationInputTokens = data.usage.cache_creation_input_tokens;
|
|
139
|
+
}
|
|
140
|
+
if (data.usage.cache_read_input_tokens !== undefined) {
|
|
141
|
+
log.cacheReadInputTokens = data.usage.cache_read_input_tokens;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
case "error":
|
|
145
|
+
// Store error info on the log for display
|
|
146
|
+
log.error = data.error.message;
|
|
136
147
|
break;
|
|
137
148
|
case "content_block_stop":
|
|
138
149
|
case "message_stop":
|
|
@@ -42,6 +42,21 @@ export function extractOpenAIStream(
|
|
|
42
42
|
|
|
43
43
|
try {
|
|
44
44
|
const parsed: unknown = JSON.parse(dataStr);
|
|
45
|
+
// Check for error object in SSE stream before trying to parse as chunk
|
|
46
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
47
|
+
const errorDesc = Object.getOwnPropertyDescriptor(parsed, "error");
|
|
48
|
+
if (
|
|
49
|
+
errorDesc !== undefined &&
|
|
50
|
+
typeof errorDesc.value === "object" &&
|
|
51
|
+
errorDesc.value !== null
|
|
52
|
+
) {
|
|
53
|
+
const msgDesc = Object.getOwnPropertyDescriptor(errorDesc.value, "message");
|
|
54
|
+
if (msgDesc !== undefined && typeof msgDesc.value === "string") {
|
|
55
|
+
log.error = msgDesc.value;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
45
60
|
const chunkResult = OpenAISSERawChunkSchema.safeParse(parsed);
|
|
46
61
|
if (!chunkResult.success) continue;
|
|
47
62
|
const chunk = chunkResult.data;
|
package/src/proxy/handler.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createLog, emitLogUpdate, type CapturedLog } from "./store";
|
|
2
|
-
import { appendLogEntry } from "./logger";
|
|
2
|
+
import { appendLogEntry, logger } from "./logger";
|
|
3
3
|
import { writeChunks } from "./chunkStorage";
|
|
4
4
|
import { extractModelFromBody, type RequestFormat } from "./schemas";
|
|
5
5
|
import { registry } from "./formats";
|
|
@@ -89,6 +89,7 @@ function buildFileLogEntry(log: CapturedLog, upstreamUrl: string): Record<string
|
|
|
89
89
|
clientProjectFolder: log.clientProjectFolder,
|
|
90
90
|
streamingChunks: log.streamingChunks,
|
|
91
91
|
streamingChunksPath: log.streamingChunksPath,
|
|
92
|
+
error: log.error,
|
|
92
93
|
};
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -252,7 +253,7 @@ function handleStreamingResponse(
|
|
|
252
253
|
);
|
|
253
254
|
log.streamingChunksPath = chunkPath;
|
|
254
255
|
}
|
|
255
|
-
appendLogEntry(
|
|
256
|
+
appendLogEntry(buildFileLogEntry(log, upstreamUrl));
|
|
256
257
|
emitLogUpdate(log);
|
|
257
258
|
},
|
|
258
259
|
});
|
|
@@ -264,18 +265,23 @@ function handleStreamingResponse(
|
|
|
264
265
|
const loggedStream = upstreamRes.body.pipeThrough(transform);
|
|
265
266
|
|
|
266
267
|
req.signal?.addEventListener("abort", () => {
|
|
267
|
-
if (log.responseText === null
|
|
268
|
-
|
|
268
|
+
if (log.responseText === null) {
|
|
269
|
+
logger.info(`[handler] Streaming client aborted: ${log.method} ${log.path}`);
|
|
269
270
|
log.elapsedMs = Date.now() - startTime;
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
271
|
+
if (chunks.length > 0) {
|
|
272
|
+
const full = chunks.join("");
|
|
273
|
+
log.responseText = formatHandler.extractStream(full, log, log.model ?? undefined, true);
|
|
274
|
+
// Persist chunks to disk on abort
|
|
275
|
+
if (log.streamingChunks && log.streamingChunks.chunks.length > 0) {
|
|
276
|
+
const chunkPath = writeChunks(
|
|
277
|
+
log.id,
|
|
278
|
+
log.streamingChunks.chunks,
|
|
279
|
+
log.streamingChunks.truncated,
|
|
280
|
+
);
|
|
281
|
+
log.streamingChunksPath = chunkPath;
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
log.responseText = "Client aborted";
|
|
279
285
|
}
|
|
280
286
|
appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: "Client aborted" });
|
|
281
287
|
emitLogUpdate(log);
|
|
@@ -296,15 +302,17 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
296
302
|
const url = new URL(req.url);
|
|
297
303
|
const parsed = parseRequestPath(req, url);
|
|
298
304
|
|
|
305
|
+
// Read body only after cheap path checks
|
|
299
306
|
let requestBody: string | null = null;
|
|
300
307
|
if (req.body && req.method !== "GET" && req.method !== "HEAD") {
|
|
301
308
|
requestBody = await req.text();
|
|
302
309
|
}
|
|
303
310
|
|
|
304
|
-
// Extract model once and reuse
|
|
311
|
+
// Extract model once and reuse - avoid duplicate parsing
|
|
305
312
|
const model = requestBody !== null ? extractModelFromBody(requestBody) : null;
|
|
306
313
|
|
|
307
|
-
|
|
314
|
+
// Find provider config using already-extracted model (not calling extractModelFromBody again)
|
|
315
|
+
const matchedProviderConfig = model !== null ? findProviderByModel(model) : null;
|
|
308
316
|
const upstreamBase = selectUpstreamBase(parsed.isChatCompletions, matchedProviderConfig);
|
|
309
317
|
const upstreamUrl = buildUpstreamUrl(upstreamBase, parsed.normalizedPath);
|
|
310
318
|
const upstreamHost = getHostFromUrl(upstreamBase);
|
|
@@ -318,6 +326,7 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
318
326
|
|
|
319
327
|
// Only proxy requests matching a registered provider
|
|
320
328
|
if (model === null || provider === null) {
|
|
329
|
+
logger.warn(`[handler] Unsupported provider: model=${model}`);
|
|
321
330
|
return new Response("Forbidden: unsupported provider", { status: STATUS_FORBIDDEN });
|
|
322
331
|
}
|
|
323
332
|
|
|
@@ -354,9 +363,19 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
354
363
|
method: req.method,
|
|
355
364
|
headers: upstreamHeaders,
|
|
356
365
|
body: requestBody,
|
|
366
|
+
signal: req.signal,
|
|
357
367
|
});
|
|
358
368
|
} catch (err) {
|
|
359
369
|
log.elapsedMs = Date.now() - startTime;
|
|
370
|
+
// Check if it was a client abort (not a proxy error)
|
|
371
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
372
|
+
logger.info(`[handler] Client aborted: ${req.method} ${parsed.apiPath}`);
|
|
373
|
+
log.responseStatus = 499; // Client Closed Request (non-standard but descriptive)
|
|
374
|
+
log.responseText = "Client aborted";
|
|
375
|
+
appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: "Client aborted" });
|
|
376
|
+
return new Response("Client aborted", { status: 499 });
|
|
377
|
+
}
|
|
378
|
+
logger.error(`[handler] Proxy error: ${req.method} ${parsed.apiPath}`, String(err));
|
|
360
379
|
log.responseStatus = STATUS_BAD_GATEWAY;
|
|
361
380
|
log.responseText = String(err);
|
|
362
381
|
appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: String(err) });
|
|
@@ -380,15 +399,3 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
380
399
|
|
|
381
400
|
return handleStreamingResponse(upstreamRes, req, startTime, formatHandler, upstreamUrl, log);
|
|
382
401
|
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Find provider configuration by model name (from persistent store).
|
|
386
|
-
*/
|
|
387
|
-
function findProviderByModelFromConfig(
|
|
388
|
-
requestBody: string | null,
|
|
389
|
-
): ReturnType<typeof findProviderByModel> {
|
|
390
|
-
if (requestBody === null) return null;
|
|
391
|
-
const model = extractModelFromBody(requestBody);
|
|
392
|
-
if (model === null) return null;
|
|
393
|
-
return findProviderByModel(model);
|
|
394
|
-
}
|
package/src/proxy/logIndex.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile, writeFile, stat, readdir, mkdir } from "node:fs/promises";
|
|
|
2
2
|
import { createReadStream, existsSync } from "node:fs";
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
|
-
import { resolveLogDir } from "./logger";
|
|
5
|
+
import { resolveLogDir, logger } from "./logger";
|
|
6
6
|
|
|
7
7
|
type LogIndexEntry = {
|
|
8
8
|
id: number;
|
|
@@ -71,8 +71,7 @@ export async function loadIndex(): Promise<LogIndex> {
|
|
|
71
71
|
}
|
|
72
72
|
return cachedIndex;
|
|
73
73
|
} catch (err) {
|
|
74
|
-
|
|
75
|
-
console.error("[logIndex] Failed to load index:", err);
|
|
74
|
+
logger.error("[logIndex] Failed to load index:", String(err));
|
|
76
75
|
cachedIndex = createEmptyIndex();
|
|
77
76
|
return cachedIndex;
|
|
78
77
|
}
|
|
@@ -91,8 +90,7 @@ export async function saveIndex(index: LogIndex): Promise<void> {
|
|
|
91
90
|
try {
|
|
92
91
|
await writeFile(indexPath, JSON.stringify(index), "utf-8");
|
|
93
92
|
} catch (err) {
|
|
94
|
-
|
|
95
|
-
console.error("[logIndex] Failed to save index:", err);
|
|
93
|
+
logger.error("[logIndex] Failed to save index:", String(err));
|
|
96
94
|
}
|
|
97
95
|
}
|
|
98
96
|
|
|
@@ -107,6 +105,36 @@ export async function addToIndex(
|
|
|
107
105
|
if (id > index.maxId) {
|
|
108
106
|
index.maxId = id;
|
|
109
107
|
}
|
|
108
|
+
// Defer disk writes to reduce I/O - flush after a batch of updates
|
|
109
|
+
scheduleIndexFlush();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Batch writes: collect pending flushes and write once
|
|
113
|
+
let indexFlushScheduled = false;
|
|
114
|
+
let indexFlushTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
115
|
+
|
|
116
|
+
async function flushIndexAsync(): Promise<void> {
|
|
117
|
+
indexFlushScheduled = false;
|
|
118
|
+
indexFlushTimeout = null;
|
|
119
|
+
const index = await loadIndex();
|
|
120
|
+
await saveIndex(index);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function scheduleIndexFlush(): void {
|
|
124
|
+
if (indexFlushScheduled) return;
|
|
125
|
+
indexFlushScheduled = true;
|
|
126
|
+
indexFlushTimeout = setTimeout(() => {
|
|
127
|
+
void flushIndexAsync();
|
|
128
|
+
}, 1000); // Flush after 1 second of inactivity
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function flushIndex(): Promise<void> {
|
|
132
|
+
if (indexFlushTimeout !== null) {
|
|
133
|
+
clearTimeout(indexFlushTimeout);
|
|
134
|
+
indexFlushTimeout = null;
|
|
135
|
+
}
|
|
136
|
+
indexFlushScheduled = false;
|
|
137
|
+
const index = await loadIndex();
|
|
110
138
|
await saveIndex(index);
|
|
111
139
|
}
|
|
112
140
|
|
|
@@ -173,9 +201,26 @@ export async function rebuildIndex(): Promise<LogIndex> {
|
|
|
173
201
|
return newIndex;
|
|
174
202
|
}
|
|
175
203
|
|
|
204
|
+
// Mutex for atomic ID generation to prevent race conditions
|
|
205
|
+
let idGenerationLock = false;
|
|
206
|
+
|
|
176
207
|
export async function getNextLogId(): Promise<number> {
|
|
177
|
-
|
|
178
|
-
|
|
208
|
+
// Wait for any ongoing ID generation to complete
|
|
209
|
+
while (idGenerationLock) {
|
|
210
|
+
// Use setTimeout(0) to yield to event loop
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
212
|
+
}
|
|
213
|
+
idGenerationLock = true;
|
|
214
|
+
try {
|
|
215
|
+
const index = await loadIndex();
|
|
216
|
+
const nextId = index.maxId + 1;
|
|
217
|
+
index.maxId = nextId;
|
|
218
|
+
// Synchronously update the index in memory (disk write is deferred via batching)
|
|
219
|
+
cachedIndex = index;
|
|
220
|
+
return nextId;
|
|
221
|
+
} finally {
|
|
222
|
+
idGenerationLock = false;
|
|
223
|
+
}
|
|
179
224
|
}
|
|
180
225
|
|
|
181
226
|
export function getCurrentLogFile(): string {
|