@victor-software-house/pi-openai-proxy 0.2.2 → 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/README.md +0 -10
- package/extensions/proxy.ts +159 -62
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -231,16 +231,6 @@ pi install npm:@victor-software-house/pi-openai-proxy
|
|
|
231
231
|
|
|
232
232
|
`/proxy` (or `/proxy config`) opens an interactive settings panel where you can configure the bind address, port, auth token, remote images, body size limit, and upstream timeout. Changes are saved to `~/.pi/agent/proxy-config.json` immediately. Restart the proxy to apply changes.
|
|
233
233
|
|
|
234
|
-
### Auto-start with a pi session
|
|
235
|
-
|
|
236
|
-
```bash
|
|
237
|
-
pi --proxy
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
The proxy starts automatically on session start and stops when the session ends. A status indicator in the footer shows the proxy URL and model count.
|
|
241
|
-
|
|
242
|
-
Note: the `--proxy` flag is registered by the extension at runtime and does not appear in `pi --help`.
|
|
243
|
-
|
|
244
234
|
### Standalone (background) mode
|
|
245
235
|
|
|
246
236
|
For a proxy that outlives pi sessions, run the binary directly:
|
package/extensions/proxy.ts
CHANGED
|
@@ -11,9 +11,6 @@
|
|
|
11
11
|
* /proxy path Show config file location
|
|
12
12
|
* /proxy reset Restore default settings
|
|
13
13
|
* /proxy help Usage line
|
|
14
|
-
*
|
|
15
|
-
* Flag:
|
|
16
|
-
* --proxy Auto-start on session start
|
|
17
14
|
*/
|
|
18
15
|
|
|
19
16
|
import {
|
|
@@ -46,6 +43,8 @@ interface ProxyConfig {
|
|
|
46
43
|
remoteImages: boolean;
|
|
47
44
|
maxBodySizeMb: number;
|
|
48
45
|
upstreamTimeoutSec: number;
|
|
46
|
+
/** "detached" = daemon that outlives the session, "session" = dies with the session */
|
|
47
|
+
lifetime: "detached" | "session";
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
interface RuntimeStatus {
|
|
@@ -65,6 +64,7 @@ const DEFAULT_CONFIG: ProxyConfig = {
|
|
|
65
64
|
remoteImages: false,
|
|
66
65
|
maxBodySizeMb: 50,
|
|
67
66
|
upstreamTimeoutSec: 120,
|
|
67
|
+
lifetime: "detached",
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
function toObject(value: unknown): Record<string, unknown> {
|
|
@@ -88,6 +88,7 @@ function normalizeConfig(raw: unknown): ProxyConfig {
|
|
|
88
88
|
remoteImages: typeof v["remoteImages"] === "boolean" ? (v["remoteImages"] as boolean) : DEFAULT_CONFIG.remoteImages,
|
|
89
89
|
maxBodySizeMb: clampInt(v["maxBodySizeMb"], 1, 500, DEFAULT_CONFIG.maxBodySizeMb),
|
|
90
90
|
upstreamTimeoutSec: clampInt(v["upstreamTimeoutSec"], 5, 600, DEFAULT_CONFIG.upstreamTimeoutSec),
|
|
91
|
+
lifetime: v["lifetime"] === "session" ? "session" : "detached",
|
|
91
92
|
};
|
|
92
93
|
}
|
|
93
94
|
|
|
@@ -145,8 +146,8 @@ function configToEnv(config: ProxyConfig): Record<string, string> {
|
|
|
145
146
|
// ---------------------------------------------------------------------------
|
|
146
147
|
|
|
147
148
|
export default function proxyExtension(pi: ExtensionAPI): void {
|
|
148
|
-
let proxyProcess: ChildProcess | undefined;
|
|
149
149
|
let config = loadConfig();
|
|
150
|
+
let sessionProcess: ChildProcess | undefined;
|
|
150
151
|
|
|
151
152
|
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
152
153
|
const packageRoot = resolve(extensionDir, "..");
|
|
@@ -156,27 +157,19 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
156
157
|
return `http://${config.host}:${String(config.port)}`;
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
// --- Flag ---
|
|
160
|
-
|
|
161
|
-
pi.registerFlag("proxy", {
|
|
162
|
-
description: "Start the OpenAI proxy on session start",
|
|
163
|
-
type: "boolean",
|
|
164
|
-
default: false,
|
|
165
|
-
});
|
|
166
|
-
|
|
167
160
|
// --- Lifecycle ---
|
|
168
161
|
|
|
169
162
|
pi.on("session_start", async (_event, ctx) => {
|
|
170
163
|
config = loadConfig();
|
|
171
|
-
|
|
172
|
-
await startProxy(ctx);
|
|
173
|
-
} else {
|
|
174
|
-
await refreshStatus(ctx);
|
|
175
|
-
}
|
|
164
|
+
await refreshStatus(ctx);
|
|
176
165
|
});
|
|
177
166
|
|
|
178
167
|
pi.on("session_shutdown", async () => {
|
|
179
|
-
|
|
168
|
+
// Only kill session-tied processes
|
|
169
|
+
if (sessionProcess !== undefined) {
|
|
170
|
+
sessionProcess.kill("SIGTERM");
|
|
171
|
+
sessionProcess = undefined;
|
|
172
|
+
}
|
|
180
173
|
});
|
|
181
174
|
|
|
182
175
|
// --- Command family ---
|
|
@@ -235,30 +228,72 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
235
228
|
},
|
|
236
229
|
});
|
|
237
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
|
+
|
|
238
276
|
// --- Proxy process management ---
|
|
239
277
|
|
|
240
278
|
async function probe(): Promise<RuntimeStatus> {
|
|
241
|
-
const managed = proxyProcess !== undefined;
|
|
242
279
|
try {
|
|
243
280
|
const res = await fetch(`${proxyUrl()}/v1/models`, {
|
|
244
281
|
signal: AbortSignal.timeout(2000),
|
|
245
282
|
});
|
|
246
283
|
if (res.ok) {
|
|
247
284
|
const body = (await res.json()) as { data?: unknown[] };
|
|
248
|
-
return { reachable: true, models: body.data?.length ?? 0
|
|
285
|
+
return { reachable: true, models: body.data?.length ?? 0 };
|
|
249
286
|
}
|
|
250
287
|
} catch {
|
|
251
288
|
// not reachable
|
|
252
289
|
}
|
|
253
|
-
return { reachable: false, models: 0
|
|
290
|
+
return { reachable: false, models: 0 };
|
|
254
291
|
}
|
|
255
292
|
|
|
256
293
|
async function refreshStatus(ctx: ExtensionContext): Promise<void> {
|
|
257
294
|
const status = await probe();
|
|
258
295
|
if (status.reachable) {
|
|
259
296
|
ctx.ui.setStatus("proxy", `proxy: ${proxyUrl()} (${String(status.models)} models)`);
|
|
260
|
-
} else if (proxyProcess !== undefined) {
|
|
261
|
-
ctx.ui.setStatus("proxy", "proxy: starting...");
|
|
262
297
|
} else {
|
|
263
298
|
ctx.ui.setStatus("proxy", undefined);
|
|
264
299
|
}
|
|
@@ -266,6 +301,8 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
266
301
|
|
|
267
302
|
async function startProxy(ctx: ExtensionContext): Promise<void> {
|
|
268
303
|
config = loadConfig();
|
|
304
|
+
|
|
305
|
+
// Already running?
|
|
269
306
|
const status = await probe();
|
|
270
307
|
if (status.reachable) {
|
|
271
308
|
ctx.ui.notify(
|
|
@@ -276,9 +313,17 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
276
313
|
return;
|
|
277
314
|
}
|
|
278
315
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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));
|
|
282
327
|
}
|
|
283
328
|
|
|
284
329
|
ctx.ui.setStatus("proxy", "proxy: starting...");
|
|
@@ -286,30 +331,17 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
286
331
|
try {
|
|
287
332
|
const proxyEnv = { ...process.env, ...configToEnv(config) };
|
|
288
333
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
proxyProcess.on("exit", (code) => {
|
|
296
|
-
proxyProcess = undefined;
|
|
297
|
-
if (code !== null && code !== 0) {
|
|
298
|
-
ctx.ui.notify(`Proxy exited with code ${String(code)}`, "warning");
|
|
299
|
-
}
|
|
300
|
-
ctx.ui.setStatus("proxy", undefined);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
proxyProcess.on("error", (err) => {
|
|
304
|
-
proxyProcess = undefined;
|
|
305
|
-
ctx.ui.notify(`Failed to start proxy: ${err.message}`, "error");
|
|
306
|
-
ctx.ui.setStatus("proxy", undefined);
|
|
307
|
-
});
|
|
334
|
+
if (config.lifetime === "detached") {
|
|
335
|
+
await startDetached(ctx, proxyEnv);
|
|
336
|
+
} else {
|
|
337
|
+
startSessionTied(ctx, proxyEnv);
|
|
338
|
+
}
|
|
308
339
|
|
|
309
340
|
const ready = await waitForReady(3000);
|
|
310
341
|
if (ready.reachable) {
|
|
342
|
+
const mode = config.lifetime === "detached" ? "background" : "session";
|
|
311
343
|
ctx.ui.notify(
|
|
312
|
-
`Proxy started at ${proxyUrl()} (${String(ready.models)} models)`,
|
|
344
|
+
`Proxy started at ${proxyUrl()} (${String(ready.models)} models) [${mode}]`,
|
|
313
345
|
"info",
|
|
314
346
|
);
|
|
315
347
|
} else {
|
|
@@ -323,36 +355,97 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
323
355
|
}
|
|
324
356
|
}
|
|
325
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
|
+
|
|
326
400
|
async function stopProxy(ctx: ExtensionContext): Promise<void> {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
401
|
+
// Session-tied process?
|
|
402
|
+
if (sessionProcess !== undefined) {
|
|
403
|
+
sessionProcess.kill("SIGTERM");
|
|
404
|
+
sessionProcess = undefined;
|
|
405
|
+
ctx.ui.notify("Session proxy stopped", "info");
|
|
330
406
|
ctx.ui.setStatus("proxy", undefined);
|
|
331
407
|
return;
|
|
332
408
|
}
|
|
333
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?
|
|
334
425
|
const status = await probe();
|
|
335
426
|
if (status.reachable) {
|
|
336
427
|
ctx.ui.notify(
|
|
337
|
-
`Proxy at ${proxyUrl()}
|
|
428
|
+
`Proxy at ${proxyUrl()} was not started by /proxy -- stop it manually`,
|
|
338
429
|
"info",
|
|
339
430
|
);
|
|
340
431
|
} else {
|
|
341
432
|
ctx.ui.notify("Proxy is not running", "info");
|
|
433
|
+
ctx.ui.setStatus("proxy", undefined);
|
|
342
434
|
}
|
|
343
435
|
}
|
|
344
436
|
|
|
345
437
|
async function showStatus(ctx: ExtensionContext): Promise<void> {
|
|
346
438
|
const status = await probe();
|
|
347
|
-
const
|
|
439
|
+
const pid = readPid();
|
|
440
|
+
const pidTag = pid !== undefined ? ` [pid ${String(pid)}]` : "";
|
|
348
441
|
|
|
349
442
|
if (status.reachable) {
|
|
350
443
|
ctx.ui.notify(
|
|
351
|
-
`${proxyUrl()}
|
|
444
|
+
`${proxyUrl()} -- ${String(status.models)} models available${pidTag}`,
|
|
352
445
|
"info",
|
|
353
446
|
);
|
|
354
447
|
} else {
|
|
355
|
-
ctx.ui.notify("Proxy not running. Use /proxy start
|
|
448
|
+
ctx.ui.notify("Proxy not running. Use /proxy start", "info");
|
|
356
449
|
}
|
|
357
450
|
await refreshStatus(ctx);
|
|
358
451
|
}
|
|
@@ -362,24 +455,18 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
362
455
|
const authDisplay =
|
|
363
456
|
config.authToken.length > 0 ? `enabled (token: ${config.authToken})` : "disabled";
|
|
364
457
|
const lines = [
|
|
458
|
+
`lifetime: ${config.lifetime}`,
|
|
365
459
|
`host: ${config.host}`,
|
|
366
460
|
`port: ${String(config.port)}`,
|
|
367
461
|
`auth: ${authDisplay}`,
|
|
368
462
|
`remote images: ${String(config.remoteImages)}`,
|
|
369
463
|
`max body: ${String(config.maxBodySizeMb)} MB`,
|
|
370
|
-
`
|
|
464
|
+
`timeout: ${String(config.upstreamTimeoutSec)}s`,
|
|
371
465
|
];
|
|
372
466
|
ctx.ui.notify(lines.join(" | "), "info");
|
|
373
467
|
await refreshStatus(ctx);
|
|
374
468
|
}
|
|
375
469
|
|
|
376
|
-
function killProxy(): void {
|
|
377
|
-
if (proxyProcess !== undefined) {
|
|
378
|
-
proxyProcess.kill("SIGTERM");
|
|
379
|
-
proxyProcess = undefined;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
470
|
async function waitForReady(timeoutMs: number): Promise<RuntimeStatus> {
|
|
384
471
|
const start = Date.now();
|
|
385
472
|
const interval = 300;
|
|
@@ -388,7 +475,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
388
475
|
if (status.reachable) return status;
|
|
389
476
|
await new Promise((r) => setTimeout(r, interval));
|
|
390
477
|
}
|
|
391
|
-
return { reachable: false, models: 0
|
|
478
|
+
return { reachable: false, models: 0 };
|
|
392
479
|
}
|
|
393
480
|
|
|
394
481
|
// --- Settings panel ---
|
|
@@ -397,6 +484,13 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
397
484
|
|
|
398
485
|
function buildSettingItems(): SettingItem[] {
|
|
399
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
|
+
},
|
|
400
494
|
{
|
|
401
495
|
id: "host",
|
|
402
496
|
label: "Bind address",
|
|
@@ -447,6 +541,9 @@ export default function proxyExtension(pi: ExtensionAPI): void {
|
|
|
447
541
|
|
|
448
542
|
function applySetting(id: string, value: string): void {
|
|
449
543
|
switch (id) {
|
|
544
|
+
case "lifetime":
|
|
545
|
+
config = { ...config, lifetime: value === "session" ? "session" : "detached" };
|
|
546
|
+
break;
|
|
450
547
|
case "host":
|
|
451
548
|
config = { ...config, host: value };
|
|
452
549
|
break;
|