@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.
Files changed (41) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-DdJSLfxK.css +1 -0
  3. package/.output/public/assets/index-DyKLPMPn.js +97 -0
  4. package/.output/public/assets/main-Cu0oTDfX.js +17 -0
  5. package/.output/server/_libs/dequal.mjs +27 -0
  6. package/.output/server/_libs/swr.mjs +938 -0
  7. package/.output/server/_libs/use-sync-external-store.mjs +64 -1
  8. package/.output/server/_ssr/{index-CAIDMqNv.mjs → index-COIATcfa.mjs} +186 -88
  9. package/.output/server/_ssr/index.mjs +2 -2
  10. package/.output/server/_ssr/{router-CsCLdrXq.mjs → router-CwmgKXBJ.mjs} +259 -74
  11. package/.output/server/{_tanstack-start-manifest_v-BF6ge6dS.mjs → _tanstack-start-manifest_v-C7hQOzvX.mjs} +1 -1
  12. package/.output/server/index.mjs +23 -23
  13. package/README.md +8 -209
  14. package/package.json +2 -1
  15. package/src/components/ProxyViewer.tsx +2 -0
  16. package/src/components/ProxyViewerContainer.tsx +10 -1
  17. package/src/components/providers/ProviderCard.tsx +57 -48
  18. package/src/components/providers/ProviderForm.tsx +21 -0
  19. package/src/components/providers/ProviderLogo.tsx +6 -1
  20. package/src/components/providers/ProvidersPanel.tsx +29 -34
  21. package/src/components/providers/SettingsDialog.tsx +5 -3
  22. package/src/components/proxy-viewer/LogEntry.tsx +7 -0
  23. package/src/components/proxy-viewer/ResponseView.tsx +32 -6
  24. package/src/lib/useProviders.ts +30 -0
  25. package/src/proxy/chunkStorage.ts +4 -6
  26. package/src/proxy/formats/anthropic/schemas.ts +9 -0
  27. package/src/proxy/formats/anthropic/stream.ts +11 -0
  28. package/src/proxy/formats/openai/stream.ts +15 -0
  29. package/src/proxy/handler.ts +34 -27
  30. package/src/proxy/logIndex.ts +52 -7
  31. package/src/proxy/logger.ts +60 -10
  32. package/src/proxy/providers.ts +5 -0
  33. package/src/proxy/schemas.ts +2 -0
  34. package/src/proxy/socketTracker.ts +90 -36
  35. package/src/proxy/store.ts +24 -14
  36. package/src/routes/__root.tsx +4 -1
  37. package/src/routes/api/providers.$providerId.ts +1 -0
  38. package/src/routes/api/providers.ts +2 -0
  39. package/.output/public/assets/index-B3RwBPLW.css +0 -1
  40. package/.output/public/assets/index-CB8ZIeEk.js +0 -97
  41. 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
- onProvidersChange?: (providers: ProviderConfig[]) => void;
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
- onProvidersChange,
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 ?? internalProviders;
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
- }, [fetchProviders]);
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: { notConfigured: true }, streaming: { notConfigured: true } },
141
- openai: { nonStreaming: { notConfigured: true }, streaming: { notConfigured: true } },
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
- await fetchProviders();
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
- await fetchProviders();
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
- await fetchProviders();
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
- await fetchProviders();
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 (isLoading && providers.length === 0) {
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 variant="outline" size="sm" onClick={() => handleExport(false)} className="gap-1">
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 variant="outline" size="sm" onClick={handleImportClick} className="gap-1">
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 type { ProviderConfig } from "../../proxy/providers";
7
+ import { useProviders } from "../../lib/useProviders";
8
8
 
9
9
  export function SettingsDialog(): JSX.Element {
10
10
  const [open, setOpen] = useState(false);
11
11
  const [activeTab, setActiveTab] = useState("providers");
12
- const [providers, setProviders] = useState<ProviderConfig[]>([]);
12
+ const { providers, mutate } = useProviders();
13
13
  const [testResults, setTestResults] = useState<
14
14
  Record<string, import("./ProvidersPanel").TestResults>
15
15
  >({});
@@ -74,7 +74,9 @@ export function SettingsDialog(): JSX.Element {
74
74
  externalTestResults={testResults}
75
75
  externalTestingProviders={testingProviders}
76
76
  externalTestingTimeLeft={testingTimeLeft}
77
- onProvidersChange={setProviders}
77
+ onProvidersMutate={() => {
78
+ void mutate();
79
+ }}
78
80
  onTestResultsChange={handleTestResultsChange}
79
81
  onTestingProvidersChange={handleTestingProvidersChange}
80
82
  onTestingTimeLeftChange={handleTestingTimeLeftChange}
@@ -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 isError = responseStatus !== null && responseStatus >= 400;
111
+ const isHttpError = responseStatus !== null && responseStatus >= 400;
109
112
 
110
- if (isError) {
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
- // eslint-disable-next-line no-console
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
- // eslint-disable-next-line no-console
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
- // eslint-disable-next-line no-console
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;
@@ -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({ ...buildFileLogEntry(log, upstreamUrl), error: null });
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 && chunks.length > 0) {
268
- const full = chunks.join("");
268
+ if (log.responseText === null) {
269
+ logger.info(`[handler] Streaming client aborted: ${log.method} ${log.path}`);
269
270
  log.elapsedMs = Date.now() - startTime;
270
- log.responseText = formatHandler.extractStream(full, log, log.model ?? undefined, true);
271
- // Persist chunks to disk on abort
272
- if (log.streamingChunks && log.streamingChunks.chunks.length > 0) {
273
- const chunkPath = writeChunks(
274
- log.id,
275
- log.streamingChunks.chunks,
276
- log.streamingChunks.truncated,
277
- );
278
- log.streamingChunksPath = chunkPath;
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
- const matchedProviderConfig = findProviderByModelFromConfig(requestBody);
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
- }
@@ -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
- // eslint-disable-next-line no-console
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
- // eslint-disable-next-line no-console
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
- const index = await loadIndex();
178
- return index.maxId + 1;
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 {