@victor-software-house/pi-openai-proxy 0.3.0 → 1.0.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.
@@ -4,141 +4,45 @@
4
4
  * Command family:
5
5
  * /proxy Open settings panel
6
6
  * /proxy start Start the proxy server
7
- * /proxy stop Stop the proxy server (session-managed only)
7
+ * /proxy stop Stop the proxy server
8
8
  * /proxy status Show proxy status
9
9
  * /proxy config Open settings panel (alias)
10
10
  * /proxy show Summarize current config
11
11
  * /proxy path Show config file location
12
12
  * /proxy reset Restore default settings
13
13
  * /proxy help Usage line
14
+ *
15
+ * Config schema imported from @victor-software-house/pi-openai-proxy/config (SSOT).
14
16
  */
15
17
 
18
+ import { type ChildProcess, spawn } from "node:child_process";
19
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import { dirname, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
16
22
  import {
17
- getSettingsListTheme,
18
23
  type ExtensionAPI,
19
24
  type ExtensionCommandContext,
20
25
  type ExtensionContext,
26
+ getSettingsListTheme,
21
27
  } from "@mariozechner/pi-coding-agent";
22
- import { Container, SettingsList, Text, type SettingItem } from "@mariozechner/pi-tui";
23
- import { spawn, type ChildProcess } from "node:child_process";
28
+ import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui";
29
+
30
+ // Config schema -- single source of truth
24
31
  import {
25
- existsSync,
26
- mkdirSync,
27
- readFileSync,
28
- renameSync,
29
- unlinkSync,
30
- writeFileSync,
31
- } from "node:fs";
32
- import { dirname, resolve } from "node:path";
33
- import { fileURLToPath } from "node:url";
32
+ configToEnv,
33
+ DEFAULT_CONFIG,
34
+ getConfigPath,
35
+ loadConfigFromFile,
36
+ saveConfigToFile,
37
+ } from "@victor-software-house/pi-openai-proxy/config";
34
38
 
35
39
  // ---------------------------------------------------------------------------
36
- // Types
40
+ // Runtime status
37
41
  // ---------------------------------------------------------------------------
38
42
 
39
- interface ProxyConfig {
40
- host: string;
41
- port: number;
42
- authToken: string;
43
- remoteImages: boolean;
44
- maxBodySizeMb: number;
45
- upstreamTimeoutSec: number;
46
- /** "detached" = daemon that outlives the session, "session" = dies with the session */
47
- lifetime: "detached" | "session";
48
- }
49
-
50
43
  interface RuntimeStatus {
51
44
  reachable: boolean;
52
45
  models: number;
53
- managed: boolean;
54
- }
55
-
56
- // ---------------------------------------------------------------------------
57
- // Defaults and normalization
58
- // ---------------------------------------------------------------------------
59
-
60
- const DEFAULT_CONFIG: ProxyConfig = {
61
- host: "127.0.0.1",
62
- port: 4141,
63
- authToken: "",
64
- remoteImages: false,
65
- maxBodySizeMb: 50,
66
- upstreamTimeoutSec: 120,
67
- lifetime: "detached",
68
- };
69
-
70
- function toObject(value: unknown): Record<string, unknown> {
71
- if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) {
72
- return {};
73
- }
74
- return value as Record<string, unknown>;
75
- }
76
-
77
- function clampInt(raw: unknown, min: number, max: number, fallback: number): number {
78
- if (typeof raw !== "number" || !Number.isFinite(raw)) return fallback;
79
- return Math.max(min, Math.min(max, Math.round(raw)));
80
- }
81
-
82
- function normalizeConfig(raw: unknown): ProxyConfig {
83
- const v = toObject(raw);
84
- return {
85
- host: typeof v["host"] === "string" && v["host"].length > 0 ? (v["host"] as string) : DEFAULT_CONFIG.host,
86
- port: clampInt(v["port"], 1, 65535, DEFAULT_CONFIG.port),
87
- authToken: typeof v["authToken"] === "string" ? (v["authToken"] as string) : DEFAULT_CONFIG.authToken,
88
- remoteImages: typeof v["remoteImages"] === "boolean" ? (v["remoteImages"] as boolean) : DEFAULT_CONFIG.remoteImages,
89
- maxBodySizeMb: clampInt(v["maxBodySizeMb"], 1, 500, DEFAULT_CONFIG.maxBodySizeMb),
90
- upstreamTimeoutSec: clampInt(v["upstreamTimeoutSec"], 5, 600, DEFAULT_CONFIG.upstreamTimeoutSec),
91
- lifetime: v["lifetime"] === "session" ? "session" : "detached",
92
- };
93
- }
94
-
95
- // ---------------------------------------------------------------------------
96
- // Config persistence
97
- // ---------------------------------------------------------------------------
98
-
99
- function getConfigPath(): string {
100
- const piDir = process.env["PI_CODING_AGENT_DIR"] ?? resolve(process.env["HOME"] ?? "~", ".pi", "agent");
101
- return resolve(piDir, "proxy-config.json");
102
- }
103
-
104
- function loadConfig(): ProxyConfig {
105
- const p = getConfigPath();
106
- if (!existsSync(p)) return { ...DEFAULT_CONFIG };
107
- try {
108
- return normalizeConfig(JSON.parse(readFileSync(p, "utf-8")));
109
- } catch {
110
- return { ...DEFAULT_CONFIG };
111
- }
112
- }
113
-
114
- function saveConfig(config: ProxyConfig): void {
115
- const p = getConfigPath();
116
- const normalized = normalizeConfig(config);
117
- const tmp = `${p}.tmp`;
118
- try {
119
- mkdirSync(dirname(p), { recursive: true });
120
- writeFileSync(tmp, `${JSON.stringify(normalized, null, "\t")}\n`, "utf-8");
121
- renameSync(tmp, p);
122
- } catch {
123
- if (existsSync(tmp)) unlinkSync(tmp);
124
- }
125
- }
126
-
127
- // ---------------------------------------------------------------------------
128
- // Config -> env vars
129
- // ---------------------------------------------------------------------------
130
-
131
- function configToEnv(config: ProxyConfig): Record<string, string> {
132
- const env: Record<string, string> = {};
133
- env["PI_PROXY_HOST"] = config.host;
134
- env["PI_PROXY_PORT"] = String(config.port);
135
- if (config.authToken.length > 0) {
136
- env["PI_PROXY_AUTH_TOKEN"] = config.authToken;
137
- }
138
- env["PI_PROXY_REMOTE_IMAGES"] = String(config.remoteImages);
139
- env["PI_PROXY_MAX_BODY_SIZE"] = String(config.maxBodySizeMb * 1024 * 1024);
140
- env["PI_PROXY_UPSTREAM_TIMEOUT_MS"] = String(config.upstreamTimeoutSec * 1000);
141
- return env;
142
46
  }
143
47
 
144
48
  // ---------------------------------------------------------------------------
@@ -146,12 +50,20 @@ function configToEnv(config: ProxyConfig): Record<string, string> {
146
50
  // ---------------------------------------------------------------------------
147
51
 
148
52
  export default function proxyExtension(pi: ExtensionAPI): void {
149
- let config = loadConfig();
53
+ let config = loadConfigFromFile();
150
54
  let sessionProcess: ChildProcess | undefined;
151
55
 
152
56
  const extensionDir = dirname(fileURLToPath(import.meta.url));
153
57
  const packageRoot = resolve(extensionDir, "..");
154
- const proxyEntry = resolve(packageRoot, "dist", "index.mjs");
58
+
59
+ // Resolve pi-proxy binary: try workspace node_modules, then global
60
+ function findProxyBinary(): string {
61
+ // In workspace: node_modules/pi-proxy/dist/index.mjs
62
+ const workspaceBin = resolve(packageRoot, "node_modules", "pi-proxy", "dist", "index.mjs");
63
+ if (existsSync(workspaceBin)) return workspaceBin;
64
+ // Fallback: assume pi-proxy is in PATH
65
+ return "pi-proxy";
66
+ }
155
67
 
156
68
  function proxyUrl(): string {
157
69
  return `http://${config.host}:${String(config.port)}`;
@@ -160,12 +72,11 @@ export default function proxyExtension(pi: ExtensionAPI): void {
160
72
  // --- Lifecycle ---
161
73
 
162
74
  pi.on("session_start", async (_event, ctx) => {
163
- config = loadConfig();
75
+ config = loadConfigFromFile();
164
76
  await refreshStatus(ctx);
165
77
  });
166
78
 
167
79
  pi.on("session_shutdown", async () => {
168
- // Only kill session-tied processes
169
80
  if (sessionProcess !== undefined) {
170
81
  sessionProcess.kill("SIGTERM");
171
82
  sessionProcess = undefined;
@@ -205,7 +116,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
205
116
  return;
206
117
  case "reset":
207
118
  config = { ...DEFAULT_CONFIG };
208
- saveConfig(config);
119
+ saveConfigToFile(config);
209
120
  ctx.ui.notify("Proxy settings reset to defaults", "info");
210
121
  return;
211
122
  case "help":
@@ -219,7 +130,6 @@ export default function proxyExtension(pi: ExtensionAPI): void {
219
130
  return;
220
131
  }
221
132
 
222
- // Default: open settings panel
223
133
  if (!ctx.hasUI) {
224
134
  ctx.ui.notify("/proxy requires interactive mode. Use /proxy show instead.", "warning");
225
135
  return;
@@ -231,7 +141,8 @@ export default function proxyExtension(pi: ExtensionAPI): void {
231
141
  // --- PID file ---
232
142
 
233
143
  function getPidPath(): string {
234
- const piDir = process.env["PI_CODING_AGENT_DIR"] ?? resolve(process.env["HOME"] ?? "~", ".pi", "agent");
144
+ const piDir =
145
+ process.env["PI_CODING_AGENT_DIR"] ?? resolve(process.env["HOME"] ?? "~", ".pi", "agent");
235
146
  return resolve(piDir, "proxy.pid");
236
147
  }
237
148
 
@@ -242,12 +153,10 @@ export default function proxyExtension(pi: ExtensionAPI): void {
242
153
  const raw = readFileSync(p, "utf-8").trim();
243
154
  const pid = Number.parseInt(raw, 10);
244
155
  if (!Number.isFinite(pid) || pid <= 0) return undefined;
245
- // Check if process is alive
246
156
  try {
247
157
  process.kill(pid, 0);
248
158
  return pid;
249
159
  } catch {
250
- // Process is dead, clean up stale PID file
251
160
  unlinkSync(p);
252
161
  return undefined;
253
162
  }
@@ -300,9 +209,8 @@ export default function proxyExtension(pi: ExtensionAPI): void {
300
209
  }
301
210
 
302
211
  async function startProxy(ctx: ExtensionContext): Promise<void> {
303
- config = loadConfig();
212
+ config = loadConfigFromFile();
304
213
 
305
- // Already running?
306
214
  const status = await probe();
307
215
  if (status.reachable) {
308
216
  ctx.ui.notify(
@@ -313,7 +221,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
313
221
  return;
314
222
  }
315
223
 
316
- // Stale PID from a previous detached run?
224
+ // Clean up stale PID
317
225
  const existingPid = readPid();
318
226
  if (existingPid !== undefined) {
319
227
  ctx.ui.notify(`Stale proxy process ${String(existingPid)} -- killing`, "warning");
@@ -329,12 +237,13 @@ export default function proxyExtension(pi: ExtensionAPI): void {
329
237
  ctx.ui.setStatus("proxy", "proxy: starting...");
330
238
 
331
239
  try {
332
- const proxyEnv = { ...process.env, ...configToEnv(config) };
240
+ const proxyEnv = configToEnv(config);
241
+ const bin = findProxyBinary();
333
242
 
334
243
  if (config.lifetime === "detached") {
335
- await startDetached(ctx, proxyEnv);
244
+ startDetached(bin, proxyEnv);
336
245
  } else {
337
- startSessionTied(ctx, proxyEnv);
246
+ startSessionTied(ctx, bin, proxyEnv);
338
247
  }
339
248
 
340
249
  const ready = await waitForReady(3000);
@@ -355,11 +264,15 @@ export default function proxyExtension(pi: ExtensionAPI): void {
355
264
  }
356
265
  }
357
266
 
358
- async function startDetached(ctx: ExtensionContext, env: Record<string, string>): Promise<void> {
359
- const child = spawn("bun", ["run", proxyEntry], {
267
+ function startDetached(bin: string, env: Record<string, string>): void {
268
+ const usesBun = bin.endsWith(".mjs");
269
+ const cmd = usesBun ? "bun" : bin;
270
+ const cmdArgs = usesBun ? ["run", bin] : [];
271
+
272
+ const child = spawn(cmd, cmdArgs, {
360
273
  stdio: ["ignore", "ignore", "ignore"],
361
274
  detached: true,
362
- env,
275
+ env: { ...process.env, ...env },
363
276
  });
364
277
 
365
278
  if (child.pid === undefined) {
@@ -370,16 +283,20 @@ export default function proxyExtension(pi: ExtensionAPI): void {
370
283
  writePid(child.pid);
371
284
  }
372
285
 
373
- function startSessionTied(ctx: ExtensionContext, env: Record<string, string>): void {
286
+ function startSessionTied(ctx: ExtensionContext, bin: string, env: Record<string, string>): void {
374
287
  if (sessionProcess !== undefined) {
375
288
  ctx.ui.notify("Session proxy already running", "info");
376
289
  return;
377
290
  }
378
291
 
379
- sessionProcess = spawn("bun", ["run", proxyEntry], {
292
+ const usesBun = bin.endsWith(".mjs");
293
+ const cmd = usesBun ? "bun" : bin;
294
+ const cmdArgs = usesBun ? ["run", bin] : [];
295
+
296
+ sessionProcess = spawn(cmd, cmdArgs, {
380
297
  stdio: ["ignore", "pipe", "pipe"],
381
298
  detached: false,
382
- env,
299
+ env: { ...process.env, ...env },
383
300
  });
384
301
 
385
302
  sessionProcess.on("exit", (code) => {
@@ -398,7 +315,6 @@ export default function proxyExtension(pi: ExtensionAPI): void {
398
315
  }
399
316
 
400
317
  async function stopProxy(ctx: ExtensionContext): Promise<void> {
401
- // Session-tied process?
402
318
  if (sessionProcess !== undefined) {
403
319
  sessionProcess.kill("SIGTERM");
404
320
  sessionProcess = undefined;
@@ -407,7 +323,6 @@ export default function proxyExtension(pi: ExtensionAPI): void {
407
323
  return;
408
324
  }
409
325
 
410
- // Detached process via PID file?
411
326
  const pid = readPid();
412
327
  if (pid !== undefined) {
413
328
  try {
@@ -421,13 +336,9 @@ export default function proxyExtension(pi: ExtensionAPI): void {
421
336
  }
422
337
  }
423
338
 
424
- // Something else listening?
425
339
  const status = await probe();
426
340
  if (status.reachable) {
427
- ctx.ui.notify(
428
- `Proxy at ${proxyUrl()} was not started by /proxy -- stop it manually`,
429
- "info",
430
- );
341
+ ctx.ui.notify(`Proxy at ${proxyUrl()} was not started by /proxy -- stop it manually`, "info");
431
342
  } else {
432
343
  ctx.ui.notify("Proxy is not running", "info");
433
344
  ctx.ui.setStatus("proxy", undefined);
@@ -440,10 +351,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
440
351
  const pidTag = pid !== undefined ? ` [pid ${String(pid)}]` : "";
441
352
 
442
353
  if (status.reachable) {
443
- ctx.ui.notify(
444
- `${proxyUrl()} -- ${String(status.models)} models available${pidTag}`,
445
- "info",
446
- );
354
+ ctx.ui.notify(`${proxyUrl()} -- ${String(status.models)} models available${pidTag}`, "info");
447
355
  } else {
448
356
  ctx.ui.notify("Proxy not running. Use /proxy start", "info");
449
357
  }
@@ -451,7 +359,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
451
359
  }
452
360
 
453
361
  async function showConfig(ctx: ExtensionContext): Promise<void> {
454
- config = loadConfig();
362
+ config = loadConfigFromFile();
455
363
  const authDisplay =
456
364
  config.authToken.length > 0 ? `enabled (token: ${config.authToken})` : "disabled";
457
365
  const lines = [
@@ -494,7 +402,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
494
402
  {
495
403
  id: "host",
496
404
  label: "Bind address",
497
- description: "Network interface to listen on (127.0.0.1 = local only, 0.0.0.0 = all)",
405
+ description: "Network interface (127.0.0.1 = local only, 0.0.0.0 = all)",
498
406
  currentValue: config.host,
499
407
  values: ["127.0.0.1", "0.0.0.0"],
500
408
  },
@@ -548,14 +456,15 @@ export default function proxyExtension(pi: ExtensionAPI): void {
548
456
  config = { ...config, host: value };
549
457
  break;
550
458
  case "port":
551
- config = { ...config, port: clampInt(Number.parseInt(value, 10), 1, 65535, config.port) };
459
+ config = {
460
+ ...config,
461
+ port: Math.max(1, Math.min(65535, Number.parseInt(value, 10) || config.port)),
462
+ };
552
463
  break;
553
464
  case "authToken":
554
- // Toggle: "enabled" keeps current token or generates one; "disabled" clears
555
465
  if (value === "disabled") {
556
466
  config = { ...config, authToken: "" };
557
467
  } else if (config.authToken.length === 0) {
558
- // Generate a random token on first enable
559
468
  const bytes = new Uint8Array(16);
560
469
  crypto.getRandomValues(bytes);
561
470
  config = {
@@ -564,7 +473,6 @@ export default function proxyExtension(pi: ExtensionAPI): void {
564
473
  .map((b) => b.toString(16).padStart(2, "0"))
565
474
  .join(""),
566
475
  };
567
- // Stash token so the caller can notify the user
568
476
  lastGeneratedToken = config.authToken;
569
477
  }
570
478
  break;
@@ -582,12 +490,12 @@ export default function proxyExtension(pi: ExtensionAPI): void {
582
490
  break;
583
491
  }
584
492
  }
585
- saveConfig(config);
586
- config = loadConfig(); // read back normalized
493
+ saveConfigToFile(config);
494
+ config = loadConfigFromFile();
587
495
  }
588
496
 
589
497
  async function openSettingsPanel(ctx: ExtensionCommandContext): Promise<void> {
590
- config = loadConfig();
498
+ config = loadConfigFromFile();
591
499
 
592
500
  await ctx.ui.custom<void>(
593
501
  (tui, theme, _kb, done) => {
@@ -616,7 +524,10 @@ export default function proxyExtension(pi: ExtensionAPI): void {
616
524
  container.addChild(settingsList);
617
525
  container.addChild(
618
526
  new Text(
619
- theme.fg("dim", "Esc: close | Arrow keys: navigate | Space: toggle | Restart proxy to apply"),
527
+ theme.fg(
528
+ "dim",
529
+ "Esc: close | Arrow keys: navigate | Space: toggle | Restart proxy to apply",
530
+ ),
620
531
  1,
621
532
  0,
622
533
  ),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-openai-proxy",
3
- "version": "0.3.0",
4
- "description": "Local OpenAI-compatible HTTP proxy built on pi's SDK",
3
+ "version": "1.0.0",
4
+ "description": "OpenAI-compatible HTTP proxy for pi's multi-provider model registry",
5
5
  "license": "MIT",
6
6
  "author": "Victor Software House",
7
7
  "repository": {
@@ -29,6 +29,16 @@
29
29
  "node": ">=20"
30
30
  },
31
31
  "type": "module",
32
+ "exports": {
33
+ ".": {
34
+ "import": "./dist/index.mjs",
35
+ "types": "./dist/index.d.mts"
36
+ },
37
+ "./config": {
38
+ "import": "./dist/config.mjs",
39
+ "types": "./dist/config.d.mts"
40
+ }
41
+ },
32
42
  "main": "dist/index.mjs",
33
43
  "bin": {
34
44
  "pi-openai-proxy": "dist/index.mjs"
@@ -40,7 +50,7 @@
40
50
  "scripts": {
41
51
  "dev": "bun src/index.ts",
42
52
  "build": "tsdown",
43
- "start": "node dist/index.mjs",
53
+ "start": "bun dist/index.mjs",
44
54
  "typecheck": "tsc --noEmit",
45
55
  "lint": "bun run lint:biome && bun run lint:oxlint",
46
56
  "lint:biome": "biome check .",
@@ -54,6 +64,8 @@
54
64
  "dependencies": {
55
65
  "@mariozechner/pi-ai": "^0.62.0",
56
66
  "@mariozechner/pi-coding-agent": "^0.62.0",
67
+ "@sinclair/typebox": "^0.34.0",
68
+ "citty": "^0.1.6",
57
69
  "hono": "^4.12.8",
58
70
  "zod": "^4.3.6"
59
71
  },
@@ -62,10 +74,6 @@
62
74
  "@commitlint/cli": "^20.5.0",
63
75
  "@commitlint/config-conventional": "^20.5.0",
64
76
  "@limegrass/eslint-plugin-import-alias": "^1.6.1",
65
- "@semantic-release/changelog": "^6.0.3",
66
- "@semantic-release/git": "^10.0.1",
67
- "@semantic-release/github": "^12.0.6",
68
- "@semantic-release/npm": "^13.1.5",
69
77
  "@types/bun": "^1.3.11",
70
78
  "@types/node": "^25.5.0",
71
79
  "eslint-plugin-zod": "^3.5.0",
@@ -73,6 +81,10 @@
73
81
  "oxlint": "^1.56.0",
74
82
  "oxlint-tsgolint": "^0.17.1",
75
83
  "semantic-release": "^25.0.3",
84
+ "@semantic-release/changelog": "^6.0.3",
85
+ "@semantic-release/git": "^10.0.1",
86
+ "@semantic-release/github": "^12.0.6",
87
+ "@semantic-release/npm": "^13.1.5",
76
88
  "tsdown": "^0.21.4",
77
89
  "typescript": "^5.9.3"
78
90
  }