@tonyclaw/llm-inspector 1.8.0 → 1.9.1
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-DH3FOgcK.js → index-BmEH5jeO.js} +18 -18
- package/.output/public/assets/{index-BLVa7n9b.css → index-DdJSLfxK.css} +1 -1
- package/.output/public/assets/{main-Beo3LJDa.js → main-GVpFMVGE.js} +1 -1
- package/.output/server/_ssr/{index-HkueJ4Un.mjs → index-D0d6QxPt.mjs} +85 -31
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-DTswxb7l.mjs → router-D9uLXa9A.mjs} +287 -67
- package/.output/server/{_tanstack-start-manifest_v-DhUuivt-.mjs → _tanstack-start-manifest_v-ByfnNZV_.mjs} +1 -1
- package/.output/server/index.mjs +26 -26
- 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/components/proxy-viewer/StreamingChunkSequence.tsx +12 -3
- 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 +44 -27
- package/src/proxy/logIndex.ts +73 -7
- package/src/proxy/logger.ts +62 -6
- 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 +32 -18
- package/src/routes/api/providers.$providerId.ts +1 -0
- package/src/routes/api/providers.ts +2 -0
|
@@ -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, initLogger, logger } from "./logger";
|
|
3
3
|
import { writeChunks } from "./chunkStorage";
|
|
4
4
|
import { extractModelFromBody, type RequestFormat } from "./schemas";
|
|
5
5
|
import { registry } from "./formats";
|
|
@@ -28,6 +28,16 @@ import {
|
|
|
28
28
|
STATUS_BAD_GATEWAY,
|
|
29
29
|
} from "./constants";
|
|
30
30
|
|
|
31
|
+
// Initialize logger at startup
|
|
32
|
+
initLogger()
|
|
33
|
+
.then(() => {
|
|
34
|
+
logger.info("Proxy handler initialized");
|
|
35
|
+
})
|
|
36
|
+
.catch((err) => {
|
|
37
|
+
// eslint-disable-next-line no-console
|
|
38
|
+
console.error("[handler] Logger initialization failed:", err);
|
|
39
|
+
});
|
|
40
|
+
|
|
31
41
|
/**
|
|
32
42
|
* Strips all custom/non-standard headers from the request and replaces with
|
|
33
43
|
* unified proxy identity. Only preserves standard HTTP headers needed for API calls.
|
|
@@ -89,6 +99,7 @@ function buildFileLogEntry(log: CapturedLog, upstreamUrl: string): Record<string
|
|
|
89
99
|
clientProjectFolder: log.clientProjectFolder,
|
|
90
100
|
streamingChunks: log.streamingChunks,
|
|
91
101
|
streamingChunksPath: log.streamingChunksPath,
|
|
102
|
+
error: log.error,
|
|
92
103
|
};
|
|
93
104
|
}
|
|
94
105
|
|
|
@@ -252,7 +263,7 @@ function handleStreamingResponse(
|
|
|
252
263
|
);
|
|
253
264
|
log.streamingChunksPath = chunkPath;
|
|
254
265
|
}
|
|
255
|
-
appendLogEntry(
|
|
266
|
+
appendLogEntry(buildFileLogEntry(log, upstreamUrl));
|
|
256
267
|
emitLogUpdate(log);
|
|
257
268
|
},
|
|
258
269
|
});
|
|
@@ -264,18 +275,23 @@ function handleStreamingResponse(
|
|
|
264
275
|
const loggedStream = upstreamRes.body.pipeThrough(transform);
|
|
265
276
|
|
|
266
277
|
req.signal?.addEventListener("abort", () => {
|
|
267
|
-
if (log.responseText === null
|
|
268
|
-
|
|
278
|
+
if (log.responseText === null) {
|
|
279
|
+
logger.info(`[handler] Streaming client aborted: ${log.method} ${log.path}`);
|
|
269
280
|
log.elapsedMs = Date.now() - startTime;
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
281
|
+
if (chunks.length > 0) {
|
|
282
|
+
const full = chunks.join("");
|
|
283
|
+
log.responseText = formatHandler.extractStream(full, log, log.model ?? undefined, true);
|
|
284
|
+
// Persist chunks to disk on abort
|
|
285
|
+
if (log.streamingChunks && log.streamingChunks.chunks.length > 0) {
|
|
286
|
+
const chunkPath = writeChunks(
|
|
287
|
+
log.id,
|
|
288
|
+
log.streamingChunks.chunks,
|
|
289
|
+
log.streamingChunks.truncated,
|
|
290
|
+
);
|
|
291
|
+
log.streamingChunksPath = chunkPath;
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
log.responseText = "Client aborted";
|
|
279
295
|
}
|
|
280
296
|
appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: "Client aborted" });
|
|
281
297
|
emitLogUpdate(log);
|
|
@@ -296,15 +312,17 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
296
312
|
const url = new URL(req.url);
|
|
297
313
|
const parsed = parseRequestPath(req, url);
|
|
298
314
|
|
|
315
|
+
// Read body only after cheap path checks
|
|
299
316
|
let requestBody: string | null = null;
|
|
300
317
|
if (req.body && req.method !== "GET" && req.method !== "HEAD") {
|
|
301
318
|
requestBody = await req.text();
|
|
302
319
|
}
|
|
303
320
|
|
|
304
|
-
// Extract model once and reuse
|
|
321
|
+
// Extract model once and reuse - avoid duplicate parsing
|
|
305
322
|
const model = requestBody !== null ? extractModelFromBody(requestBody) : null;
|
|
306
323
|
|
|
307
|
-
|
|
324
|
+
// Find provider config using already-extracted model (not calling extractModelFromBody again)
|
|
325
|
+
const matchedProviderConfig = model !== null ? findProviderByModel(model) : null;
|
|
308
326
|
const upstreamBase = selectUpstreamBase(parsed.isChatCompletions, matchedProviderConfig);
|
|
309
327
|
const upstreamUrl = buildUpstreamUrl(upstreamBase, parsed.normalizedPath);
|
|
310
328
|
const upstreamHost = getHostFromUrl(upstreamBase);
|
|
@@ -318,6 +336,7 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
318
336
|
|
|
319
337
|
// Only proxy requests matching a registered provider
|
|
320
338
|
if (model === null || provider === null) {
|
|
339
|
+
logger.warn(`[handler] Unsupported provider: model=${model}`);
|
|
321
340
|
return new Response("Forbidden: unsupported provider", { status: STATUS_FORBIDDEN });
|
|
322
341
|
}
|
|
323
342
|
|
|
@@ -354,9 +373,19 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
354
373
|
method: req.method,
|
|
355
374
|
headers: upstreamHeaders,
|
|
356
375
|
body: requestBody,
|
|
376
|
+
signal: req.signal,
|
|
357
377
|
});
|
|
358
378
|
} catch (err) {
|
|
359
379
|
log.elapsedMs = Date.now() - startTime;
|
|
380
|
+
// Check if it was a client abort (not a proxy error)
|
|
381
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
382
|
+
logger.info(`[handler] Client aborted: ${req.method} ${parsed.apiPath}`);
|
|
383
|
+
log.responseStatus = 499; // Client Closed Request (non-standard but descriptive)
|
|
384
|
+
log.responseText = "Client aborted";
|
|
385
|
+
appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: "Client aborted" });
|
|
386
|
+
return new Response("Client aborted", { status: 499 });
|
|
387
|
+
}
|
|
388
|
+
logger.error(`[handler] Proxy error: ${req.method} ${parsed.apiPath}`, String(err));
|
|
360
389
|
log.responseStatus = STATUS_BAD_GATEWAY;
|
|
361
390
|
log.responseText = String(err);
|
|
362
391
|
appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: String(err) });
|
|
@@ -380,15 +409,3 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
380
409
|
|
|
381
410
|
return handleStreamingResponse(upstreamRes, req, startTime, formatHandler, upstreamUrl, log);
|
|
382
411
|
}
|
|
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,47 @@ export async function rebuildIndex(): Promise<LogIndex> {
|
|
|
173
201
|
return newIndex;
|
|
174
202
|
}
|
|
175
203
|
|
|
204
|
+
// Async mutex for atomic ID generation to prevent race conditions
|
|
205
|
+
// Uses a promise queue instead of busy-waiting
|
|
206
|
+
let idGenerationPromise: Promise<void> | null = null;
|
|
207
|
+
let releaseLock: (() => void) | null = null;
|
|
208
|
+
|
|
209
|
+
async function acquireLock(): Promise<void> {
|
|
210
|
+
if (releaseLock === null) {
|
|
211
|
+
idGenerationPromise = new Promise<void>((resolve) => {
|
|
212
|
+
releaseLock = resolve;
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
// Wait for the previous lock to be released
|
|
216
|
+
await idGenerationPromise;
|
|
217
|
+
// After waiting, we need to create a new promise for the next waiter
|
|
218
|
+
idGenerationPromise = new Promise<void>((resolve) => {
|
|
219
|
+
releaseLock = resolve;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function releaseLockFn(): void {
|
|
225
|
+
if (releaseLock) {
|
|
226
|
+
const resolve = releaseLock;
|
|
227
|
+
releaseLock = null;
|
|
228
|
+
idGenerationPromise = null;
|
|
229
|
+
resolve();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
176
233
|
export async function getNextLogId(): Promise<number> {
|
|
177
|
-
|
|
178
|
-
|
|
234
|
+
await acquireLock();
|
|
235
|
+
try {
|
|
236
|
+
const index = await loadIndex();
|
|
237
|
+
const nextId = index.maxId + 1;
|
|
238
|
+
index.maxId = nextId;
|
|
239
|
+
// Synchronously update the index in memory (disk write is deferred via batching)
|
|
240
|
+
cachedIndex = index;
|
|
241
|
+
return nextId;
|
|
242
|
+
} finally {
|
|
243
|
+
releaseLockFn();
|
|
244
|
+
}
|
|
179
245
|
}
|
|
180
246
|
|
|
181
247
|
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,63 @@ 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 - not critical
|
|
56
64
|
}
|
|
57
65
|
}
|
|
58
66
|
} catch (err) {
|
|
67
|
+
// Log directory initialization errors but don't fail startup
|
|
68
|
+
// eslint-disable-next-line no-console
|
|
69
|
+
console.error("[logger] Failed to initialize log directory:", err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// File-based logger for application logs (not log entries)
|
|
74
|
+
async function writeAppLog(message: string): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
const logPath = getInspectorLogPath();
|
|
77
|
+
const logDirPath = path.dirname(logPath);
|
|
78
|
+
await mkdir(logDirPath, { recursive: true });
|
|
79
|
+
const timestamp = new Date().toISOString();
|
|
80
|
+
await appendFile(logPath, `[${timestamp}] ${message}\n`, "utf-8");
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// Log to stderr since file logging failed
|
|
59
83
|
// eslint-disable-next-line no-console
|
|
60
|
-
console.error(
|
|
84
|
+
console.error(`[logger] Failed to write to ${getInspectorLogPath()}:`, err);
|
|
61
85
|
}
|
|
62
86
|
}
|
|
63
87
|
|
|
88
|
+
// Application logger - writes to inspector.log
|
|
89
|
+
export const logger = {
|
|
90
|
+
debug(message: string, ...args: unknown[]): void {
|
|
91
|
+
const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
|
|
92
|
+
void writeAppLog(`[DEBUG] ${msg}`);
|
|
93
|
+
},
|
|
94
|
+
info(message: string, ...args: unknown[]): void {
|
|
95
|
+
const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
|
|
96
|
+
void writeAppLog(`[INFO] ${msg}`);
|
|
97
|
+
},
|
|
98
|
+
warn(message: string, ...args: unknown[]): void {
|
|
99
|
+
const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
|
|
100
|
+
void writeAppLog(`[WARN] ${msg}`);
|
|
101
|
+
},
|
|
102
|
+
error(message: string, ...args: unknown[]): void {
|
|
103
|
+
const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
|
|
104
|
+
void writeAppLog(`[ERROR] ${msg}`);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
64
108
|
// Write buffer for batching async writes
|
|
109
|
+
const MAX_BUFFER_SIZE = 1000; // Prevent unbounded memory growth
|
|
65
110
|
let writeBuffer: string[] = [];
|
|
66
111
|
let writeScheduled = false;
|
|
112
|
+
let isFlushing = false; // Prevent concurrent flush operations
|
|
67
113
|
|
|
68
114
|
async function flushWriteBuffer(): Promise<void> {
|
|
69
115
|
if (writeBuffer.length === 0) return;
|
|
116
|
+
if (isFlushing) return; // Skip if another flush is in progress
|
|
70
117
|
|
|
118
|
+
isFlushing = true;
|
|
71
119
|
const toWrite = writeBuffer.join("");
|
|
72
120
|
writeBuffer = [];
|
|
73
121
|
|
|
@@ -76,8 +124,11 @@ async function flushWriteBuffer(): Promise<void> {
|
|
|
76
124
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
77
125
|
await appendFile(filePath, toWrite, "utf-8");
|
|
78
126
|
} catch (err) {
|
|
127
|
+
// Log write buffer errors but don't throw to prevent cascading failures
|
|
79
128
|
// eslint-disable-next-line no-console
|
|
80
|
-
console.error("[logger] Failed to write
|
|
129
|
+
console.error("[logger] Failed to flush write buffer:", err);
|
|
130
|
+
} finally {
|
|
131
|
+
isFlushing = false;
|
|
81
132
|
}
|
|
82
133
|
}
|
|
83
134
|
|
|
@@ -95,5 +146,10 @@ function scheduleFlush(): void {
|
|
|
95
146
|
export function appendLogEntry(entry: Record<string, unknown>): void {
|
|
96
147
|
const line = JSON.stringify(entry) + "\n";
|
|
97
148
|
writeBuffer.push(line);
|
|
98
|
-
|
|
149
|
+
// Flush immediately if buffer exceeds max size to prevent OOM
|
|
150
|
+
if (writeBuffer.length >= MAX_BUFFER_SIZE) {
|
|
151
|
+
void flushWriteBuffer();
|
|
152
|
+
} else {
|
|
153
|
+
scheduleFlush();
|
|
154
|
+
}
|
|
99
155
|
}
|
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 };
|