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