@victor-software-house/pi-openai-proxy 0.2.3 → 0.3.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/extensions/proxy.ts +158 -46
- package/package.json +1 -1
package/extensions/proxy.ts
CHANGED
|
@@ -43,6 +43,8 @@ interface ProxyConfig {
|
|
|
43
43
|
remoteImages: boolean;
|
|
44
44
|
maxBodySizeMb: number;
|
|
45
45
|
upstreamTimeoutSec: number;
|
|
46
|
+
/** "detached" = daemon that outlives the session, "session" = dies with the session */
|
|
47
|
+
lifetime: "detached" | "session";
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
interface RuntimeStatus {
|
|
@@ -62,6 +64,7 @@ const DEFAULT_CONFIG: ProxyConfig = {
|
|
|
62
64
|
remoteImages: false,
|
|
63
65
|
maxBodySizeMb: 50,
|
|
64
66
|
upstreamTimeoutSec: 120,
|
|
67
|
+
lifetime: "detached",
|
|
65
68
|
};
|
|
66
69
|
|
|
67
70
|
function toObject(value: unknown): Record<string, unknown> {
|
|
@@ -85,6 +88,7 @@ function normalizeConfig(raw: unknown): ProxyConfig {
|
|
|
85
88
|
remoteImages: typeof v["remoteImages"] === "boolean" ? (v["remoteImages"] as boolean) : DEFAULT_CONFIG.remoteImages,
|
|
86
89
|
maxBodySizeMb: clampInt(v["maxBodySizeMb"], 1, 500, DEFAULT_CONFIG.maxBodySizeMb),
|
|
87
90
|
upstreamTimeoutSec: clampInt(v["upstreamTimeoutSec"], 5, 600, DEFAULT_CONFIG.upstreamTimeoutSec),
|
|
91
|
+
lifetime: v["lifetime"] === "session" ? "session" : "detached",
|
|
88
92
|
};
|
|
89
93
|
}
|
|
90
94
|
|
|
@@ -142,8 +146,8 @@ function configToEnv(config: ProxyConfig): Record<string, string> {
|
|
|
142
146
|
// ---------------------------------------------------------------------------
|
|
143
147
|
|
|
144
148
|
export default function proxyExtension(pi: ExtensionAPI): void {
|
|
145
|
-
let proxyProcess: ChildProcess | undefined;
|
|
146
149
|
let config = loadConfig();
|
|
150
|
+
let sessionProcess: ChildProcess | undefined;
|
|
147
151
|
|
|
148
152
|
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
149
153
|
const packageRoot = resolve(extensionDir, "..");
|
|
@@ -161,7 +165,11 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
161
165
|
});
|
|
162
166
|
|
|
163
167
|
pi.on("session_shutdown", async () => {
|
|
164
|
-
|
|
168
|
+
// Only kill session-tied processes
|
|
169
|
+
if (sessionProcess !== undefined) {
|
|
170
|
+
sessionProcess.kill("SIGTERM");
|
|
171
|
+
sessionProcess = undefined;
|
|
172
|
+
}
|
|
165
173
|
});
|
|
166
174
|
|
|
167
175
|
// --- Command family ---
|
|
@@ -220,30 +228,72 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
220
228
|
},
|
|
221
229
|
});
|
|
222
230
|
|
|
231
|
+
// --- PID file ---
|
|
232
|
+
|
|
233
|
+
function getPidPath(): string {
|
|
234
|
+
const piDir = process.env["PI_CODING_AGENT_DIR"] ?? resolve(process.env["HOME"] ?? "~", ".pi", "agent");
|
|
235
|
+
return resolve(piDir, "proxy.pid");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readPid(): number | undefined {
|
|
239
|
+
const p = getPidPath();
|
|
240
|
+
if (!existsSync(p)) return undefined;
|
|
241
|
+
try {
|
|
242
|
+
const raw = readFileSync(p, "utf-8").trim();
|
|
243
|
+
const pid = Number.parseInt(raw, 10);
|
|
244
|
+
if (!Number.isFinite(pid) || pid <= 0) return undefined;
|
|
245
|
+
// Check if process is alive
|
|
246
|
+
try {
|
|
247
|
+
process.kill(pid, 0);
|
|
248
|
+
return pid;
|
|
249
|
+
} catch {
|
|
250
|
+
// Process is dead, clean up stale PID file
|
|
251
|
+
unlinkSync(p);
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function writePid(pid: number): void {
|
|
260
|
+
const p = getPidPath();
|
|
261
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
262
|
+
writeFileSync(p, String(pid), "utf-8");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function removePid(): void {
|
|
266
|
+
const p = getPidPath();
|
|
267
|
+
if (existsSync(p)) {
|
|
268
|
+
try {
|
|
269
|
+
unlinkSync(p);
|
|
270
|
+
} catch {
|
|
271
|
+
// ignore
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
223
276
|
// --- Proxy process management ---
|
|
224
277
|
|
|
225
278
|
async function probe(): Promise<RuntimeStatus> {
|
|
226
|
-
const managed = proxyProcess !== undefined;
|
|
227
279
|
try {
|
|
228
280
|
const res = await fetch(`${proxyUrl()}/v1/models`, {
|
|
229
281
|
signal: AbortSignal.timeout(2000),
|
|
230
282
|
});
|
|
231
283
|
if (res.ok) {
|
|
232
284
|
const body = (await res.json()) as { data?: unknown[] };
|
|
233
|
-
return { reachable: true, models: body.data?.length ?? 0
|
|
285
|
+
return { reachable: true, models: body.data?.length ?? 0 };
|
|
234
286
|
}
|
|
235
287
|
} catch {
|
|
236
288
|
// not reachable
|
|
237
289
|
}
|
|
238
|
-
return { reachable: false, models: 0
|
|
290
|
+
return { reachable: false, models: 0 };
|
|
239
291
|
}
|
|
240
292
|
|
|
241
293
|
async function refreshStatus(ctx: ExtensionContext): Promise<void> {
|
|
242
294
|
const status = await probe();
|
|
243
295
|
if (status.reachable) {
|
|
244
296
|
ctx.ui.setStatus("proxy", `proxy: ${proxyUrl()} (${String(status.models)} models)`);
|
|
245
|
-
} else if (proxyProcess !== undefined) {
|
|
246
|
-
ctx.ui.setStatus("proxy", "proxy: starting...");
|
|
247
297
|
} else {
|
|
248
298
|
ctx.ui.setStatus("proxy", undefined);
|
|
249
299
|
}
|
|
@@ -251,6 +301,8 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
251
301
|
|
|
252
302
|
async function startProxy(ctx: ExtensionContext): Promise<void> {
|
|
253
303
|
config = loadConfig();
|
|
304
|
+
|
|
305
|
+
// Already running?
|
|
254
306
|
const status = await probe();
|
|
255
307
|
if (status.reachable) {
|
|
256
308
|
ctx.ui.notify(
|
|
@@ -261,9 +313,17 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
261
313
|
return;
|
|
262
314
|
}
|
|
263
315
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
316
|
+
// Stale PID from a previous detached run?
|
|
317
|
+
const existingPid = readPid();
|
|
318
|
+
if (existingPid !== undefined) {
|
|
319
|
+
ctx.ui.notify(`Stale proxy process ${String(existingPid)} -- killing`, "warning");
|
|
320
|
+
try {
|
|
321
|
+
process.kill(existingPid, "SIGTERM");
|
|
322
|
+
} catch {
|
|
323
|
+
// already dead
|
|
324
|
+
}
|
|
325
|
+
removePid();
|
|
326
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
267
327
|
}
|
|
268
328
|
|
|
269
329
|
ctx.ui.setStatus("proxy", "proxy: starting...");
|
|
@@ -271,30 +331,17 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
271
331
|
try {
|
|
272
332
|
const proxyEnv = { ...process.env, ...configToEnv(config) };
|
|
273
333
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
proxyProcess.on("exit", (code) => {
|
|
281
|
-
proxyProcess = undefined;
|
|
282
|
-
if (code !== null && code !== 0) {
|
|
283
|
-
ctx.ui.notify(`Proxy exited with code ${String(code)}`, "warning");
|
|
284
|
-
}
|
|
285
|
-
ctx.ui.setStatus("proxy", undefined);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
proxyProcess.on("error", (err) => {
|
|
289
|
-
proxyProcess = undefined;
|
|
290
|
-
ctx.ui.notify(`Failed to start proxy: ${err.message}`, "error");
|
|
291
|
-
ctx.ui.setStatus("proxy", undefined);
|
|
292
|
-
});
|
|
334
|
+
if (config.lifetime === "detached") {
|
|
335
|
+
await startDetached(ctx, proxyEnv);
|
|
336
|
+
} else {
|
|
337
|
+
startSessionTied(ctx, proxyEnv);
|
|
338
|
+
}
|
|
293
339
|
|
|
294
340
|
const ready = await waitForReady(3000);
|
|
295
341
|
if (ready.reachable) {
|
|
342
|
+
const mode = config.lifetime === "detached" ? "background" : "session";
|
|
296
343
|
ctx.ui.notify(
|
|
297
|
-
`Proxy started at ${proxyUrl()} (${String(ready.models)} models)`,
|
|
344
|
+
`Proxy started at ${proxyUrl()} (${String(ready.models)} models) [${mode}]`,
|
|
298
345
|
"info",
|
|
299
346
|
);
|
|
300
347
|
} else {
|
|
@@ -308,36 +355,97 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
308
355
|
}
|
|
309
356
|
}
|
|
310
357
|
|
|
358
|
+
async function startDetached(ctx: ExtensionContext, env: Record<string, string>): Promise<void> {
|
|
359
|
+
const child = spawn("bun", ["run", proxyEntry], {
|
|
360
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
361
|
+
detached: true,
|
|
362
|
+
env,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (child.pid === undefined) {
|
|
366
|
+
throw new Error("No PID returned from spawn");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
child.unref();
|
|
370
|
+
writePid(child.pid);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function startSessionTied(ctx: ExtensionContext, env: Record<string, string>): void {
|
|
374
|
+
if (sessionProcess !== undefined) {
|
|
375
|
+
ctx.ui.notify("Session proxy already running", "info");
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
sessionProcess = spawn("bun", ["run", proxyEntry], {
|
|
380
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
381
|
+
detached: false,
|
|
382
|
+
env,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
sessionProcess.on("exit", (code) => {
|
|
386
|
+
sessionProcess = undefined;
|
|
387
|
+
if (code !== null && code !== 0) {
|
|
388
|
+
ctx.ui.notify(`Proxy exited with code ${String(code)}`, "warning");
|
|
389
|
+
}
|
|
390
|
+
ctx.ui.setStatus("proxy", undefined);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
sessionProcess.on("error", (err) => {
|
|
394
|
+
sessionProcess = undefined;
|
|
395
|
+
ctx.ui.notify(`Proxy error: ${err.message}`, "error");
|
|
396
|
+
ctx.ui.setStatus("proxy", undefined);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
311
400
|
async function stopProxy(ctx: ExtensionContext): Promise<void> {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
401
|
+
// Session-tied process?
|
|
402
|
+
if (sessionProcess !== undefined) {
|
|
403
|
+
sessionProcess.kill("SIGTERM");
|
|
404
|
+
sessionProcess = undefined;
|
|
405
|
+
ctx.ui.notify("Session proxy stopped", "info");
|
|
315
406
|
ctx.ui.setStatus("proxy", undefined);
|
|
316
407
|
return;
|
|
317
408
|
}
|
|
318
409
|
|
|
410
|
+
// Detached process via PID file?
|
|
411
|
+
const pid = readPid();
|
|
412
|
+
if (pid !== undefined) {
|
|
413
|
+
try {
|
|
414
|
+
process.kill(pid, "SIGTERM");
|
|
415
|
+
removePid();
|
|
416
|
+
ctx.ui.notify(`Proxy stopped (pid ${String(pid)})`, "info");
|
|
417
|
+
ctx.ui.setStatus("proxy", undefined);
|
|
418
|
+
return;
|
|
419
|
+
} catch {
|
|
420
|
+
removePid();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Something else listening?
|
|
319
425
|
const status = await probe();
|
|
320
426
|
if (status.reachable) {
|
|
321
427
|
ctx.ui.notify(
|
|
322
|
-
`Proxy at ${proxyUrl()}
|
|
428
|
+
`Proxy at ${proxyUrl()} was not started by /proxy -- stop it manually`,
|
|
323
429
|
"info",
|
|
324
430
|
);
|
|
325
431
|
} else {
|
|
326
432
|
ctx.ui.notify("Proxy is not running", "info");
|
|
433
|
+
ctx.ui.setStatus("proxy", undefined);
|
|
327
434
|
}
|
|
328
435
|
}
|
|
329
436
|
|
|
330
437
|
async function showStatus(ctx: ExtensionContext): Promise<void> {
|
|
331
438
|
const status = await probe();
|
|
332
|
-
const
|
|
439
|
+
const pid = readPid();
|
|
440
|
+
const pidTag = pid !== undefined ? ` [pid ${String(pid)}]` : "";
|
|
333
441
|
|
|
334
442
|
if (status.reachable) {
|
|
335
443
|
ctx.ui.notify(
|
|
336
|
-
`${proxyUrl()}
|
|
444
|
+
`${proxyUrl()} -- ${String(status.models)} models available${pidTag}`,
|
|
337
445
|
"info",
|
|
338
446
|
);
|
|
339
447
|
} else {
|
|
340
|
-
ctx.ui.notify("Proxy not running. Use /proxy start
|
|
448
|
+
ctx.ui.notify("Proxy not running. Use /proxy start", "info");
|
|
341
449
|
}
|
|
342
450
|
await refreshStatus(ctx);
|
|
343
451
|
}
|
|
@@ -347,24 +455,18 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
347
455
|
const authDisplay =
|
|
348
456
|
config.authToken.length > 0 ? `enabled (token: ${config.authToken})` : "disabled";
|
|
349
457
|
const lines = [
|
|
458
|
+
`lifetime: ${config.lifetime}`,
|
|
350
459
|
`host: ${config.host}`,
|
|
351
460
|
`port: ${String(config.port)}`,
|
|
352
461
|
`auth: ${authDisplay}`,
|
|
353
462
|
`remote images: ${String(config.remoteImages)}`,
|
|
354
463
|
`max body: ${String(config.maxBodySizeMb)} MB`,
|
|
355
|
-
`
|
|
464
|
+
`timeout: ${String(config.upstreamTimeoutSec)}s`,
|
|
356
465
|
];
|
|
357
466
|
ctx.ui.notify(lines.join(" | "), "info");
|
|
358
467
|
await refreshStatus(ctx);
|
|
359
468
|
}
|
|
360
469
|
|
|
361
|
-
function killProxy(): void {
|
|
362
|
-
if (proxyProcess !== undefined) {
|
|
363
|
-
proxyProcess.kill("SIGTERM");
|
|
364
|
-
proxyProcess = undefined;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
470
|
async function waitForReady(timeoutMs: number): Promise<RuntimeStatus> {
|
|
369
471
|
const start = Date.now();
|
|
370
472
|
const interval = 300;
|
|
@@ -373,7 +475,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
373
475
|
if (status.reachable) return status;
|
|
374
476
|
await new Promise((r) => setTimeout(r, interval));
|
|
375
477
|
}
|
|
376
|
-
return { reachable: false, models: 0
|
|
478
|
+
return { reachable: false, models: 0 };
|
|
377
479
|
}
|
|
378
480
|
|
|
379
481
|
// --- Settings panel ---
|
|
@@ -382,6 +484,13 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
382
484
|
|
|
383
485
|
function buildSettingItems(): SettingItem[] {
|
|
384
486
|
return [
|
|
487
|
+
{
|
|
488
|
+
id: "lifetime",
|
|
489
|
+
label: "Lifetime",
|
|
490
|
+
description: "detached = background daemon, session = dies when pi exits",
|
|
491
|
+
currentValue: config.lifetime,
|
|
492
|
+
values: ["detached", "session"],
|
|
493
|
+
},
|
|
385
494
|
{
|
|
386
495
|
id: "host",
|
|
387
496
|
label: "Bind address",
|
|
@@ -432,6 +541,9 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
432
541
|
|
|
433
542
|
function applySetting(id: string, value: string): void {
|
|
434
543
|
switch (id) {
|
|
544
|
+
case "lifetime":
|
|
545
|
+
config = { ...config, lifetime: value === "session" ? "session" : "detached" };
|
|
546
|
+
break;
|
|
435
547
|
case "host":
|
|
436
548
|
config = { ...config, host: value };
|
|
437
549
|
break;
|