@victor-software-house/pi-openai-proxy 0.3.1 → 2.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,153 +4,66 @@
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
46
  }
54
47
 
55
- // ---------------------------------------------------------------------------
56
- // Defaults and normalization
57
- // ---------------------------------------------------------------------------
58
-
59
- const DEFAULT_CONFIG: ProxyConfig = {
60
- host: "127.0.0.1",
61
- port: 4141,
62
- authToken: "",
63
- remoteImages: false,
64
- maxBodySizeMb: 50,
65
- upstreamTimeoutSec: 120,
66
- lifetime: "detached",
67
- };
68
-
69
- function toObject(value: unknown): Record<string, unknown> {
70
- if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) {
71
- return {};
72
- }
73
- return value as Record<string, unknown>;
74
- }
75
-
76
- function clampInt(raw: unknown, min: number, max: number, fallback: number): number {
77
- if (typeof raw !== "number" || !Number.isFinite(raw)) return fallback;
78
- return Math.max(min, Math.min(max, Math.round(raw)));
79
- }
80
-
81
- function normalizeConfig(raw: unknown): ProxyConfig {
82
- const v = toObject(raw);
83
- return {
84
- host: typeof v["host"] === "string" && v["host"].length > 0 ? (v["host"] as string) : DEFAULT_CONFIG.host,
85
- port: clampInt(v["port"], 1, 65535, DEFAULT_CONFIG.port),
86
- authToken: typeof v["authToken"] === "string" ? (v["authToken"] as string) : DEFAULT_CONFIG.authToken,
87
- remoteImages: typeof v["remoteImages"] === "boolean" ? (v["remoteImages"] as boolean) : DEFAULT_CONFIG.remoteImages,
88
- maxBodySizeMb: clampInt(v["maxBodySizeMb"], 1, 500, DEFAULT_CONFIG.maxBodySizeMb),
89
- upstreamTimeoutSec: clampInt(v["upstreamTimeoutSec"], 5, 600, DEFAULT_CONFIG.upstreamTimeoutSec),
90
- lifetime: v["lifetime"] === "session" ? "session" : "detached",
91
- };
92
- }
93
-
94
- // ---------------------------------------------------------------------------
95
- // Config persistence
96
- // ---------------------------------------------------------------------------
97
-
98
- function getConfigPath(): string {
99
- const piDir = process.env["PI_CODING_AGENT_DIR"] ?? resolve(process.env["HOME"] ?? "~", ".pi", "agent");
100
- return resolve(piDir, "proxy-config.json");
101
- }
102
-
103
- function loadConfig(): ProxyConfig {
104
- const p = getConfigPath();
105
- if (!existsSync(p)) return { ...DEFAULT_CONFIG };
106
- try {
107
- return normalizeConfig(JSON.parse(readFileSync(p, "utf-8")));
108
- } catch {
109
- return { ...DEFAULT_CONFIG };
110
- }
111
- }
112
-
113
- function saveConfig(config: ProxyConfig): void {
114
- const p = getConfigPath();
115
- const normalized = normalizeConfig(config);
116
- const tmp = `${p}.tmp`;
117
- try {
118
- mkdirSync(dirname(p), { recursive: true });
119
- writeFileSync(tmp, `${JSON.stringify(normalized, null, "\t")}\n`, "utf-8");
120
- renameSync(tmp, p);
121
- } catch {
122
- if (existsSync(tmp)) unlinkSync(tmp);
123
- }
124
- }
125
-
126
- // ---------------------------------------------------------------------------
127
- // Config -> env vars
128
- // ---------------------------------------------------------------------------
129
-
130
- function configToEnv(config: ProxyConfig): Record<string, string> {
131
- const env: Record<string, string> = {};
132
- env["PI_PROXY_HOST"] = config.host;
133
- env["PI_PROXY_PORT"] = String(config.port);
134
- if (config.authToken.length > 0) {
135
- env["PI_PROXY_AUTH_TOKEN"] = config.authToken;
136
- }
137
- env["PI_PROXY_REMOTE_IMAGES"] = String(config.remoteImages);
138
- env["PI_PROXY_MAX_BODY_SIZE"] = String(config.maxBodySizeMb * 1024 * 1024);
139
- env["PI_PROXY_UPSTREAM_TIMEOUT_MS"] = String(config.upstreamTimeoutSec * 1000);
140
- return env;
141
- }
142
-
143
48
  // ---------------------------------------------------------------------------
144
49
  // Extension
145
50
  // ---------------------------------------------------------------------------
146
51
 
147
52
  export default function proxyExtension(pi: ExtensionAPI): void {
148
- let config = loadConfig();
53
+ let config = loadConfigFromFile();
149
54
  let sessionProcess: ChildProcess | undefined;
150
55
 
151
56
  const extensionDir = dirname(fileURLToPath(import.meta.url));
152
57
  const packageRoot = resolve(extensionDir, "..");
153
- 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
+ }
154
67
 
155
68
  function proxyUrl(): string {
156
69
  return `http://${config.host}:${String(config.port)}`;
@@ -159,12 +72,11 @@ export default function proxyExtension(pi: ExtensionAPI): void {
159
72
  // --- Lifecycle ---
160
73
 
161
74
  pi.on("session_start", async (_event, ctx) => {
162
- config = loadConfig();
75
+ config = loadConfigFromFile();
163
76
  await refreshStatus(ctx);
164
77
  });
165
78
 
166
79
  pi.on("session_shutdown", async () => {
167
- // Only kill session-tied processes
168
80
  if (sessionProcess !== undefined) {
169
81
  sessionProcess.kill("SIGTERM");
170
82
  sessionProcess = undefined;
@@ -204,7 +116,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
204
116
  return;
205
117
  case "reset":
206
118
  config = { ...DEFAULT_CONFIG };
207
- saveConfig(config);
119
+ saveConfigToFile(config);
208
120
  ctx.ui.notify("Proxy settings reset to defaults", "info");
209
121
  return;
210
122
  case "help":
@@ -218,7 +130,6 @@ export default function proxyExtension(pi: ExtensionAPI): void {
218
130
  return;
219
131
  }
220
132
 
221
- // Default: open settings panel
222
133
  if (!ctx.hasUI) {
223
134
  ctx.ui.notify("/proxy requires interactive mode. Use /proxy show instead.", "warning");
224
135
  return;
@@ -230,7 +141,8 @@ export default function proxyExtension(pi: ExtensionAPI): void {
230
141
  // --- PID file ---
231
142
 
232
143
  function getPidPath(): string {
233
- 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");
234
146
  return resolve(piDir, "proxy.pid");
235
147
  }
236
148
 
@@ -241,12 +153,10 @@ export default function proxyExtension(pi: ExtensionAPI): void {
241
153
  const raw = readFileSync(p, "utf-8").trim();
242
154
  const pid = Number.parseInt(raw, 10);
243
155
  if (!Number.isFinite(pid) || pid <= 0) return undefined;
244
- // Check if process is alive
245
156
  try {
246
157
  process.kill(pid, 0);
247
158
  return pid;
248
159
  } catch {
249
- // Process is dead, clean up stale PID file
250
160
  unlinkSync(p);
251
161
  return undefined;
252
162
  }
@@ -299,9 +209,8 @@ export default function proxyExtension(pi: ExtensionAPI): void {
299
209
  }
300
210
 
301
211
  async function startProxy(ctx: ExtensionContext): Promise<void> {
302
- config = loadConfig();
212
+ config = loadConfigFromFile();
303
213
 
304
- // Already running?
305
214
  const status = await probe();
306
215
  if (status.reachable) {
307
216
  ctx.ui.notify(
@@ -312,7 +221,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
312
221
  return;
313
222
  }
314
223
 
315
- // Stale PID from a previous detached run?
224
+ // Clean up stale PID
316
225
  const existingPid = readPid();
317
226
  if (existingPid !== undefined) {
318
227
  ctx.ui.notify(`Stale proxy process ${String(existingPid)} -- killing`, "warning");
@@ -329,11 +238,12 @@ export default function proxyExtension(pi: ExtensionAPI): void {
329
238
 
330
239
  try {
331
240
  const proxyEnv = configToEnv(config);
241
+ const bin = findProxyBinary();
332
242
 
333
243
  if (config.lifetime === "detached") {
334
- await startDetached(ctx, proxyEnv);
244
+ startDetached(bin, proxyEnv);
335
245
  } else {
336
- startSessionTied(ctx, proxyEnv);
246
+ startSessionTied(ctx, bin, proxyEnv);
337
247
  }
338
248
 
339
249
  const ready = await waitForReady(3000);
@@ -354,8 +264,12 @@ export default function proxyExtension(pi: ExtensionAPI): void {
354
264
  }
355
265
  }
356
266
 
357
- async function startDetached(_ctx: ExtensionContext, env: Record<string, string>): Promise<void> {
358
- 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, {
359
273
  stdio: ["ignore", "ignore", "ignore"],
360
274
  detached: true,
361
275
  env: { ...process.env, ...env },
@@ -369,13 +283,17 @@ export default function proxyExtension(pi: ExtensionAPI): void {
369
283
  writePid(child.pid);
370
284
  }
371
285
 
372
- function startSessionTied(ctx: ExtensionContext, env: Record<string, string>): void {
286
+ function startSessionTied(ctx: ExtensionContext, bin: string, env: Record<string, string>): void {
373
287
  if (sessionProcess !== undefined) {
374
288
  ctx.ui.notify("Session proxy already running", "info");
375
289
  return;
376
290
  }
377
291
 
378
- 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, {
379
297
  stdio: ["ignore", "pipe", "pipe"],
380
298
  detached: false,
381
299
  env: { ...process.env, ...env },
@@ -397,7 +315,6 @@ export default function proxyExtension(pi: ExtensionAPI): void {
397
315
  }
398
316
 
399
317
  async function stopProxy(ctx: ExtensionContext): Promise<void> {
400
- // Session-tied process?
401
318
  if (sessionProcess !== undefined) {
402
319
  sessionProcess.kill("SIGTERM");
403
320
  sessionProcess = undefined;
@@ -406,7 +323,6 @@ export default function proxyExtension(pi: ExtensionAPI): void {
406
323
  return;
407
324
  }
408
325
 
409
- // Detached process via PID file?
410
326
  const pid = readPid();
411
327
  if (pid !== undefined) {
412
328
  try {
@@ -420,13 +336,9 @@ export default function proxyExtension(pi: ExtensionAPI): void {
420
336
  }
421
337
  }
422
338
 
423
- // Something else listening?
424
339
  const status = await probe();
425
340
  if (status.reachable) {
426
- ctx.ui.notify(
427
- `Proxy at ${proxyUrl()} was not started by /proxy -- stop it manually`,
428
- "info",
429
- );
341
+ ctx.ui.notify(`Proxy at ${proxyUrl()} was not started by /proxy -- stop it manually`, "info");
430
342
  } else {
431
343
  ctx.ui.notify("Proxy is not running", "info");
432
344
  ctx.ui.setStatus("proxy", undefined);
@@ -439,10 +351,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
439
351
  const pidTag = pid !== undefined ? ` [pid ${String(pid)}]` : "";
440
352
 
441
353
  if (status.reachable) {
442
- ctx.ui.notify(
443
- `${proxyUrl()} -- ${String(status.models)} models available${pidTag}`,
444
- "info",
445
- );
354
+ ctx.ui.notify(`${proxyUrl()} -- ${String(status.models)} models available${pidTag}`, "info");
446
355
  } else {
447
356
  ctx.ui.notify("Proxy not running. Use /proxy start", "info");
448
357
  }
@@ -450,7 +359,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
450
359
  }
451
360
 
452
361
  async function showConfig(ctx: ExtensionContext): Promise<void> {
453
- config = loadConfig();
362
+ config = loadConfigFromFile();
454
363
  const authDisplay =
455
364
  config.authToken.length > 0 ? `enabled (token: ${config.authToken})` : "disabled";
456
365
  const lines = [
@@ -493,7 +402,7 @@ export default function proxyExtension(pi: ExtensionAPI): void {
493
402
  {
494
403
  id: "host",
495
404
  label: "Bind address",
496
- 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)",
497
406
  currentValue: config.host,
498
407
  values: ["127.0.0.1", "0.0.0.0"],
499
408
  },
@@ -547,14 +456,15 @@ export default function proxyExtension(pi: ExtensionAPI): void {
547
456
  config = { ...config, host: value };
548
457
  break;
549
458
  case "port":
550
- 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
+ };
551
463
  break;
552
464
  case "authToken":
553
- // Toggle: "enabled" keeps current token or generates one; "disabled" clears
554
465
  if (value === "disabled") {
555
466
  config = { ...config, authToken: "" };
556
467
  } else if (config.authToken.length === 0) {
557
- // Generate a random token on first enable
558
468
  const bytes = new Uint8Array(16);
559
469
  crypto.getRandomValues(bytes);
560
470
  config = {
@@ -563,7 +473,6 @@ export default function proxyExtension(pi: ExtensionAPI): void {
563
473
  .map((b) => b.toString(16).padStart(2, "0"))
564
474
  .join(""),
565
475
  };
566
- // Stash token so the caller can notify the user
567
476
  lastGeneratedToken = config.authToken;
568
477
  }
569
478
  break;
@@ -581,12 +490,12 @@ export default function proxyExtension(pi: ExtensionAPI): void {
581
490
  break;
582
491
  }
583
492
  }
584
- saveConfig(config);
585
- config = loadConfig(); // read back normalized
493
+ saveConfigToFile(config);
494
+ config = loadConfigFromFile();
586
495
  }
587
496
 
588
497
  async function openSettingsPanel(ctx: ExtensionCommandContext): Promise<void> {
589
- config = loadConfig();
498
+ config = loadConfigFromFile();
590
499
 
591
500
  await ctx.ui.custom<void>(
592
501
  (tui, theme, _kb, done) => {
@@ -615,7 +524,10 @@ export default function proxyExtension(pi: ExtensionAPI): void {
615
524
  container.addChild(settingsList);
616
525
  container.addChild(
617
526
  new Text(
618
- 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
+ ),
619
531
  1,
620
532
  0,
621
533
  ),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-openai-proxy",
3
- "version": "0.3.1",
4
- "description": "Local OpenAI-compatible HTTP proxy built on pi's SDK",
3
+ "version": "2.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
  }