@tonyclaw/llm-inspector 1.8.0 → 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-BLVa7n9b.css → index-DdJSLfxK.css} +1 -1
- package/.output/public/assets/{index-DH3FOgcK.js → index-DyKLPMPn.js} +18 -18
- package/.output/public/assets/{main-Beo3LJDa.js → main-Cu0oTDfX.js} +1 -1
- package/.output/server/_ssr/{index-HkueJ4Un.mjs → index-COIATcfa.mjs} +74 -28
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-DTswxb7l.mjs → router-CwmgKXBJ.mjs} +236 -65
- package/.output/server/{_tanstack-start-manifest_v-DhUuivt-.mjs → _tanstack-start-manifest_v-C7hQOzvX.mjs} +1 -1
- package/.output/server/index.mjs +23 -23
- package/README.md +8 -209
- package/package.json +1 -1
- package/src/components/ProxyViewerContainer.tsx +10 -1
- package/src/components/providers/ProviderCard.tsx +19 -15
- package/src/components/providers/ProviderForm.tsx +21 -0
- package/src/components/proxy-viewer/LogEntry.tsx +7 -0
- package/src/components/proxy-viewer/ResponseView.tsx +32 -6
- 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/api/providers.$providerId.ts +1 -0
- package/src/routes/api/providers.ts +2 -0
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 {
|
package/src/proxy/logger.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
const LOG_DIR_ENV = process.env["LOG_DIR"];
|
|
5
5
|
const RETENTION_DAYS = Number(process.env["LOG_RETENTION_DAYS"] ?? "7");
|
|
6
|
+
const LOG_FILE_ENV = process.env["LLM_INSPECTOR_LOG_FILE"];
|
|
6
7
|
|
|
7
8
|
function getUserDataDir(): string {
|
|
8
9
|
if (process.platform === "win32") {
|
|
@@ -35,6 +36,14 @@ export function getLogFilePath(): string {
|
|
|
35
36
|
return path.join(resolveLogDir(), `${yyyy}-${mm}-${dd}.jsonl`);
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function getInspectorLogPath(): string {
|
|
40
|
+
if (LOG_FILE_ENV !== undefined) {
|
|
41
|
+
return LOG_FILE_ENV;
|
|
42
|
+
}
|
|
43
|
+
const base = getUserDataDir();
|
|
44
|
+
return path.join(base, ".llm-inspector", "logs", "inspector.log");
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
export async function initLogger(): Promise<void> {
|
|
39
48
|
const dir = resolveLogDir();
|
|
40
49
|
const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
@@ -50,24 +59,59 @@ export async function initLogger(): Promise<void> {
|
|
|
50
59
|
if (s.mtimeMs < cutoff) {
|
|
51
60
|
await unlink(fullPath);
|
|
52
61
|
}
|
|
53
|
-
} catch
|
|
54
|
-
//
|
|
55
|
-
console.error("[logger] Failed to stat/remove log file:", err);
|
|
62
|
+
} catch {
|
|
63
|
+
// Skip files that can't be stat
|
|
56
64
|
}
|
|
57
65
|
}
|
|
58
|
-
} catch
|
|
59
|
-
//
|
|
60
|
-
|
|
66
|
+
} catch {
|
|
67
|
+
// Skip directory initialization errors
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// File-based logger for application logs (not log entries)
|
|
72
|
+
async function writeAppLog(message: string): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
const logPath = getInspectorLogPath();
|
|
75
|
+
const logDirPath = path.dirname(logPath);
|
|
76
|
+
await mkdir(logDirPath, { recursive: true });
|
|
77
|
+
const timestamp = new Date().toISOString();
|
|
78
|
+
await appendFile(logPath, `[${timestamp}] ${message}\n`, "utf-8");
|
|
79
|
+
} catch {
|
|
80
|
+
// Silently fail if we can't write to log
|
|
61
81
|
}
|
|
62
82
|
}
|
|
63
83
|
|
|
84
|
+
// Application logger - writes to inspector.log
|
|
85
|
+
export const logger = {
|
|
86
|
+
debug(message: string, ...args: unknown[]): void {
|
|
87
|
+
const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
|
|
88
|
+
void writeAppLog(`[DEBUG] ${msg}`);
|
|
89
|
+
},
|
|
90
|
+
info(message: string, ...args: unknown[]): void {
|
|
91
|
+
const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
|
|
92
|
+
void writeAppLog(`[INFO] ${msg}`);
|
|
93
|
+
},
|
|
94
|
+
warn(message: string, ...args: unknown[]): void {
|
|
95
|
+
const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
|
|
96
|
+
void writeAppLog(`[WARN] ${msg}`);
|
|
97
|
+
},
|
|
98
|
+
error(message: string, ...args: unknown[]): void {
|
|
99
|
+
const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
|
|
100
|
+
void writeAppLog(`[ERROR] ${msg}`);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
64
104
|
// Write buffer for batching async writes
|
|
105
|
+
const MAX_BUFFER_SIZE = 1000; // Prevent unbounded memory growth
|
|
65
106
|
let writeBuffer: string[] = [];
|
|
66
107
|
let writeScheduled = false;
|
|
108
|
+
let isFlushing = false; // Prevent concurrent flush operations
|
|
67
109
|
|
|
68
110
|
async function flushWriteBuffer(): Promise<void> {
|
|
69
111
|
if (writeBuffer.length === 0) return;
|
|
112
|
+
if (isFlushing) return; // Skip if another flush is in progress
|
|
70
113
|
|
|
114
|
+
isFlushing = true;
|
|
71
115
|
const toWrite = writeBuffer.join("");
|
|
72
116
|
writeBuffer = [];
|
|
73
117
|
|
|
@@ -75,9 +119,10 @@ async function flushWriteBuffer(): Promise<void> {
|
|
|
75
119
|
const filePath = getLogFilePath();
|
|
76
120
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
77
121
|
await appendFile(filePath, toWrite, "utf-8");
|
|
78
|
-
} catch
|
|
79
|
-
//
|
|
80
|
-
|
|
122
|
+
} catch {
|
|
123
|
+
// Silently fail write buffer errors
|
|
124
|
+
} finally {
|
|
125
|
+
isFlushing = false;
|
|
81
126
|
}
|
|
82
127
|
}
|
|
83
128
|
|
|
@@ -95,5 +140,10 @@ function scheduleFlush(): void {
|
|
|
95
140
|
export function appendLogEntry(entry: Record<string, unknown>): void {
|
|
96
141
|
const line = JSON.stringify(entry) + "\n";
|
|
97
142
|
writeBuffer.push(line);
|
|
98
|
-
|
|
143
|
+
// Flush immediately if buffer exceeds max size to prevent OOM
|
|
144
|
+
if (writeBuffer.length >= MAX_BUFFER_SIZE) {
|
|
145
|
+
void flushWriteBuffer();
|
|
146
|
+
} else {
|
|
147
|
+
scheduleFlush();
|
|
148
|
+
}
|
|
99
149
|
}
|
package/src/proxy/providers.ts
CHANGED
|
@@ -17,6 +17,8 @@ export const ProviderConfigSchema = z.object({
|
|
|
17
17
|
openaiBaseUrl: z.string().optional(),
|
|
18
18
|
/** Auth header to use: "bearer" (default) or "x-api-key" */
|
|
19
19
|
authHeader: z.enum(["bearer", "x-api-key"]).optional().default("bearer"),
|
|
20
|
+
/** API documentation URL */
|
|
21
|
+
apiDocsUrl: z.string().optional(),
|
|
20
22
|
createdAt: z.string(),
|
|
21
23
|
updatedAt: z.string(),
|
|
22
24
|
});
|
|
@@ -142,6 +144,7 @@ export function addProvider(
|
|
|
142
144
|
baseUrl?: string,
|
|
143
145
|
model?: string,
|
|
144
146
|
authHeader?: "bearer" | "x-api-key",
|
|
147
|
+
apiDocsUrl?: string,
|
|
145
148
|
): ProviderConfig {
|
|
146
149
|
const providers = getProviders();
|
|
147
150
|
const now = new Date().toISOString();
|
|
@@ -153,6 +156,7 @@ export function addProvider(
|
|
|
153
156
|
baseUrl,
|
|
154
157
|
model,
|
|
155
158
|
authHeader: authHeader ?? "bearer",
|
|
159
|
+
apiDocsUrl,
|
|
156
160
|
createdAt: now,
|
|
157
161
|
updatedAt: now,
|
|
158
162
|
anthropicBaseUrl: format === "anthropic" && baseUrl !== undefined ? baseUrl : "",
|
|
@@ -179,6 +183,7 @@ export function updateProvider(
|
|
|
179
183
|
format: updates.format ?? existing.format,
|
|
180
184
|
baseUrl: updates.baseUrl !== undefined ? updates.baseUrl : existing.baseUrl,
|
|
181
185
|
authHeader: updates.authHeader ?? existing.authHeader,
|
|
186
|
+
apiDocsUrl: updates.apiDocsUrl ?? existing.apiDocsUrl,
|
|
182
187
|
createdAt: existing.createdAt,
|
|
183
188
|
updatedAt: new Date().toISOString(),
|
|
184
189
|
// Handle format-specific URLs
|
package/src/proxy/schemas.ts
CHANGED
|
@@ -61,6 +61,8 @@ export const CapturedLogSchema = z.object({
|
|
|
61
61
|
clientProjectFolder: z.string().nullable().optional(),
|
|
62
62
|
streamingChunks: StreamingChunksArraySchema.optional(),
|
|
63
63
|
streamingChunksPath: z.string().nullable().optional(),
|
|
64
|
+
/** Error message from streaming response (e.g., SSE error event) */
|
|
65
|
+
error: z.string().nullable().optional(),
|
|
64
66
|
});
|
|
65
67
|
|
|
66
68
|
export type CapturedLog = z.infer<typeof CapturedLogSchema>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import { logger } from "./logger";
|
|
3
4
|
|
|
4
5
|
const execAsync = promisify(exec);
|
|
5
6
|
|
|
@@ -55,30 +56,49 @@ function extractRemotePort(request: Request): number | null {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
|
-
* Look up PID from a local port using
|
|
59
|
+
* Look up PID from a local port using platform-appropriate command.
|
|
59
60
|
* Returns PID as number, or null if not found.
|
|
60
61
|
*/
|
|
61
62
|
async function lookupPidByPort(port: number): Promise<number | null> {
|
|
63
|
+
const platform = process.platform;
|
|
64
|
+
|
|
62
65
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
66
|
+
if (platform === "win32") {
|
|
67
|
+
// Windows: use PowerShell Get-NetTCPConnection (faster than netstat)
|
|
68
|
+
const { stdout } = await execAsync(
|
|
69
|
+
`powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -State Established | Select-Object -ExpandProperty OwningProcess"`,
|
|
70
|
+
{ windowsHide: true },
|
|
71
|
+
);
|
|
72
|
+
const pid = parseInt(stdout.trim(), 10);
|
|
73
|
+
return !isNaN(pid) && pid > 0 ? pid : null;
|
|
74
|
+
} else if (platform === "darwin") {
|
|
75
|
+
// macOS: use lsof
|
|
76
|
+
const { stdout } = await execAsync(`lsof -i :${port} -sTCP:ESTABLISHED -t 2>/dev/null`, {
|
|
77
|
+
shell: "/bin/sh",
|
|
78
|
+
});
|
|
79
|
+
const pid = parseInt(stdout.trim(), 10);
|
|
80
|
+
return !isNaN(pid) && pid > 0 ? pid : null;
|
|
81
|
+
} else {
|
|
82
|
+
// Linux: use ss (faster than netstat) or fall back to netstat
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await execAsync(
|
|
85
|
+
`ss -tlnp 'sport = :${port}' 2>/dev/null | grep ESTAB | awk '{print $6}' | grep -o 'pid=[0-9]*' | cut -d= -f2`,
|
|
86
|
+
{ shell: "/bin/sh" },
|
|
87
|
+
);
|
|
88
|
+
const pid = parseInt(stdout.trim(), 10);
|
|
77
89
|
if (!isNaN(pid) && pid > 0) return pid;
|
|
90
|
+
} catch {
|
|
91
|
+
// Fall back to netstat
|
|
78
92
|
}
|
|
93
|
+
const { stdout } = await execAsync(
|
|
94
|
+
`netstat -tan 2>/dev/null | grep ':${port}' | grep ESTABLISHED | awk '{print $7}' | cut -d/ -f1`,
|
|
95
|
+
{ shell: "/bin/sh" },
|
|
96
|
+
);
|
|
97
|
+
const pid = parseInt(stdout.trim(), 10);
|
|
98
|
+
return !isNaN(pid) && pid > 0 ? pid : null;
|
|
79
99
|
}
|
|
80
|
-
|
|
81
|
-
|
|
100
|
+
} catch (err) {
|
|
101
|
+
logger.debug(`[socketTracker] Failed to lookup PID for port ${port}:`, String(err));
|
|
82
102
|
return null;
|
|
83
103
|
}
|
|
84
104
|
}
|
|
@@ -90,35 +110,69 @@ async function lookupPidByPort(port: number): Promise<number | null> {
|
|
|
90
110
|
async function lookupProcessInfo(
|
|
91
111
|
pid: number,
|
|
92
112
|
): Promise<{ cwd: string | null; projectFolder: string | null }> {
|
|
113
|
+
const platform = process.platform;
|
|
114
|
+
|
|
93
115
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
116
|
+
if (platform === "win32") {
|
|
117
|
+
// Windows: use wmic
|
|
118
|
+
const { stdout } = await execAsync(
|
|
119
|
+
`wmic process where processid=${pid} get commandline /value`,
|
|
120
|
+
{ windowsHide: true },
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (line.includes("=")) {
|
|
126
|
+
const commandLine = (line.split("=")[1] ?? "").trim();
|
|
127
|
+
if (commandLine) {
|
|
128
|
+
const exeMatch = commandLine.match(/^"([^"]+)"/) || commandLine.match(/^([^\s]+)/);
|
|
129
|
+
if (exeMatch && exeMatch[1] !== undefined) {
|
|
130
|
+
const exePath = exeMatch[1];
|
|
131
|
+
const exeDir = exePath.substring(0, exePath.lastIndexOf("\\"));
|
|
132
|
+
return {
|
|
133
|
+
cwd: exeDir,
|
|
134
|
+
projectFolder: exeDir.split("\\").pop() ?? null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
// Unix/Linux/macOS: read from /proc or use ps
|
|
142
|
+
try {
|
|
143
|
+
// Try reading /proc/PID/cmdline (Linux)
|
|
144
|
+
const { stdout } = await execAsync(`cat /proc/${pid}/cmdline 2>/dev/null | tr '\\0' ' '`, {
|
|
145
|
+
shell: "/bin/sh",
|
|
146
|
+
});
|
|
147
|
+
const commandLine = stdout.trim();
|
|
104
148
|
if (commandLine) {
|
|
105
|
-
// Extract CWD from command line
|
|
106
|
-
// The command typically looks like: "C:\path\to\ai-tool\resources\app\bin\cli\win32-x64\tool.exe" ...
|
|
107
|
-
// or "C:\Users\user\AppData\Local\Programs\Claude\Claude.exe" --some-flag
|
|
108
|
-
// We need to find the working directory - it's usually the directory containing the exe
|
|
109
|
-
// but we also need to check if a --parent-dir flag was passed
|
|
110
|
-
|
|
111
|
-
// Parse the command line to find the executable path
|
|
112
149
|
const exeMatch = commandLine.match(/^"([^"]+)"/) || commandLine.match(/^([^\s]+)/);
|
|
113
150
|
if (exeMatch && exeMatch[1] !== undefined) {
|
|
114
151
|
const exePath = exeMatch[1];
|
|
115
|
-
const exeDir = exePath.substring(0, exePath.lastIndexOf("
|
|
152
|
+
const exeDir = exePath.substring(0, exePath.lastIndexOf("/"));
|
|
116
153
|
return {
|
|
117
154
|
cwd: exeDir,
|
|
118
|
-
projectFolder: exeDir.split("
|
|
155
|
+
projectFolder: exeDir.split("/").pop() ?? null,
|
|
119
156
|
};
|
|
120
157
|
}
|
|
121
158
|
}
|
|
159
|
+
} catch {
|
|
160
|
+
// Fall back to ps
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// macOS fallback
|
|
164
|
+
if (platform === "darwin") {
|
|
165
|
+
const { stdout } = await execAsync(`ps -p ${pid} -o comm= 2>/dev/null`, {
|
|
166
|
+
shell: "/bin/sh",
|
|
167
|
+
});
|
|
168
|
+
const exePath = stdout.trim();
|
|
169
|
+
if (exePath) {
|
|
170
|
+
const exeDir = exePath.substring(0, exePath.lastIndexOf("/"));
|
|
171
|
+
return {
|
|
172
|
+
cwd: exeDir,
|
|
173
|
+
projectFolder: exePath.split("/").pop() ?? null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
122
176
|
}
|
|
123
177
|
}
|
|
124
178
|
return { cwd: null, projectFolder: null };
|
package/src/proxy/store.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { readFileSync, existsSync, createReadStream } from "node:fs";
|
|
3
|
+
import { readdir as readdirCallback } from "node:fs/promises";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { appendLogEntry, resolveLogDir } from "./logger";
|
|
6
|
+
import { appendLogEntry, resolveLogDir, logger } from "./logger";
|
|
7
7
|
import {
|
|
8
8
|
addToIndex,
|
|
9
9
|
findInIndex,
|
|
@@ -258,16 +258,15 @@ export async function getLogById(id: number): Promise<CapturedLog | null> {
|
|
|
258
258
|
// Fallback: return the last matching entry (incomplete is better than nothing)
|
|
259
259
|
if (lastMatch !== null) return lastMatch;
|
|
260
260
|
} catch (err) {
|
|
261
|
-
|
|
262
|
-
console.error("[store] Failed to read log from disk:", err);
|
|
261
|
+
logger.error("[store] Failed to read log from disk:", String(err));
|
|
263
262
|
}
|
|
264
263
|
|
|
265
264
|
return null;
|
|
266
265
|
}
|
|
267
266
|
|
|
268
267
|
export function getFilteredLogs(sessionId?: string, model?: string): CapturedLog[] {
|
|
269
|
-
//
|
|
270
|
-
const cachedLogs = Array.from(memoryCache.values())
|
|
268
|
+
// Cache maintains insertion order (sorted by ID since logs are added in ID order)
|
|
269
|
+
const cachedLogs = Array.from(memoryCache.values());
|
|
271
270
|
|
|
272
271
|
return cachedLogs.filter((l) => {
|
|
273
272
|
if (sessionId !== undefined && l.sessionId !== sessionId) return false;
|
|
@@ -293,20 +292,32 @@ export function getModels(): string[] {
|
|
|
293
292
|
}
|
|
294
293
|
|
|
295
294
|
/**
|
|
296
|
-
* Load recent completed log entries from
|
|
295
|
+
* Load recent completed log entries from all log files into the memory cache.
|
|
297
296
|
* Called on server startup so the frontend shows existing logs.
|
|
298
297
|
*/
|
|
299
298
|
export async function loadLogsIntoMemory(): Promise<void> {
|
|
300
299
|
const logDir = resolveLogDir();
|
|
301
300
|
if (!existsSync(logDir)) return;
|
|
302
301
|
|
|
303
|
-
const today = getCurrentLogFile();
|
|
304
|
-
const filePath = join(logDir, today);
|
|
305
|
-
if (!existsSync(filePath)) return;
|
|
306
|
-
|
|
307
302
|
// Keep track of IDs seen so we only load the completed entry for each
|
|
308
303
|
const seenComplete = new Set<number>();
|
|
309
304
|
|
|
305
|
+
// Get all .jsonl files sorted by name (date)
|
|
306
|
+
try {
|
|
307
|
+
const entries = await readdirCallback(logDir);
|
|
308
|
+
const filesWithExt = entries.filter((f: string) => f.endsWith(".jsonl")).sort();
|
|
309
|
+
for (const file of filesWithExt) {
|
|
310
|
+
const filePath = join(logDir, file);
|
|
311
|
+
await loadLogFile(filePath, seenComplete);
|
|
312
|
+
}
|
|
313
|
+
} catch (err) {
|
|
314
|
+
logger.error("[store] Failed to read log directory:", String(err));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function loadLogFile(filePath: string, seenComplete: Set<number>): Promise<void> {
|
|
319
|
+
if (!existsSync(filePath)) return;
|
|
320
|
+
|
|
310
321
|
try {
|
|
311
322
|
const fileStream = createInterface({
|
|
312
323
|
input: createReadStream(filePath),
|
|
@@ -348,8 +359,7 @@ export async function loadLogsIntoMemory(): Promise<void> {
|
|
|
348
359
|
}
|
|
349
360
|
}
|
|
350
361
|
} catch (err) {
|
|
351
|
-
|
|
352
|
-
console.error("[store] Failed to load logs into memory:", err);
|
|
362
|
+
logger.error("[store] Failed to load logs into memory:", String(err));
|
|
353
363
|
}
|
|
354
364
|
}
|
|
355
365
|
|
|
@@ -11,6 +11,7 @@ const ProviderUpdateSchema = z.object({
|
|
|
11
11
|
authHeader: z.enum(["bearer", "x-api-key"]).optional(),
|
|
12
12
|
anthropicBaseUrl: z.string().optional(),
|
|
13
13
|
openaiBaseUrl: z.string().optional(),
|
|
14
|
+
apiDocsUrl: z.string().optional(),
|
|
14
15
|
});
|
|
15
16
|
|
|
16
17
|
export const Route = createFileRoute("/api/providers/$providerId")({
|
|
@@ -9,6 +9,7 @@ const ProviderInputSchema = z.object({
|
|
|
9
9
|
baseUrl: z.string().min(1, "Base URL is required"),
|
|
10
10
|
model: z.string().min(1, "Model is required"),
|
|
11
11
|
authHeader: z.enum(["bearer", "x-api-key"]).optional().default("bearer"),
|
|
12
|
+
apiDocsUrl: z.string().optional(),
|
|
12
13
|
});
|
|
13
14
|
|
|
14
15
|
export const Route = createFileRoute("/api/providers")({
|
|
@@ -29,6 +30,7 @@ export const Route = createFileRoute("/api/providers")({
|
|
|
29
30
|
parsed.data.baseUrl,
|
|
30
31
|
parsed.data.model,
|
|
31
32
|
parsed.data.authHeader,
|
|
33
|
+
parsed.data.apiDocsUrl,
|
|
32
34
|
);
|
|
33
35
|
return Response.json(newProvider, { status: 201 });
|
|
34
36
|
},
|