@victor-software-house/pi-openai-proxy 0.1.1 → 0.2.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/README.md CHANGED
@@ -135,21 +135,46 @@ openrouter/anthropic/claude-sonnet-4-20250514
135
135
 
136
136
  ## Configuration
137
137
 
138
- The proxy reuses pi's existing configuration:
138
+ ### What comes from pi
139
139
 
140
- - **API keys**: `~/.pi/agent/auth.json` (managed by `pi /login`)
141
- - **Custom models**: `~/.pi/agent/models.json`
140
+ The proxy reads two files from pi's configuration directory (`~/.pi/agent/`):
142
141
 
143
- ### Environment Variables
144
-
145
- | Variable | Default | Description |
142
+ | File | Managed by | What the proxy uses |
146
143
  |---|---|---|
147
- | `PI_PROXY_HOST` | `127.0.0.1` | Bind address |
148
- | `PI_PROXY_PORT` | `4141` | Listen port |
149
- | `PI_PROXY_AUTH_TOKEN` | (disabled) | Bearer token for proxy authentication |
150
- | `PI_PROXY_REMOTE_IMAGES` | `false` | Enable remote image URL fetching |
151
- | `PI_PROXY_MAX_BODY_SIZE` | `52428800` (50 MB) | Maximum request body size in bytes |
152
- | `PI_PROXY_UPSTREAM_TIMEOUT_MS` | `120000` (120s) | Upstream request timeout in milliseconds |
144
+ | `auth.json` | `pi /login` | API keys for each provider (Anthropic, OpenAI, Google, etc.) |
145
+ | `models.json` | pi built-in + user edits | Model definitions, capabilities, and pricing |
146
+
147
+ The proxy does **not** read pi's `settings.json` (installed packages, enabled extensions) or session-level model filters (`--models` flag). All models with configured credentials are exposed through the proxy, regardless of pi session scope.
148
+
149
+ ### What the proxy adds
150
+
151
+ Proxy-specific settings are configured via environment variables or the `/proxy config` panel (when installed as a pi package):
152
+
153
+ | Setting | Env variable | Default | Description |
154
+ |---|---|---|---|
155
+ | Bind address | `PI_PROXY_HOST` | `127.0.0.1` | Network interface (`127.0.0.1` = local only, `0.0.0.0` = all) |
156
+ | Port | `PI_PROXY_PORT` | `4141` | HTTP listen port |
157
+ | Auth token | `PI_PROXY_AUTH_TOKEN` | (disabled) | Bearer token for proxy authentication |
158
+ | Remote images | `PI_PROXY_REMOTE_IMAGES` | `false` | Allow remote image URL fetching |
159
+ | Max body size | `PI_PROXY_MAX_BODY_SIZE` | `52428800` (50 MB) | Maximum request body size in bytes |
160
+ | Upstream timeout | `PI_PROXY_UPSTREAM_TIMEOUT_MS` | `120000` (120s) | Upstream request timeout in milliseconds |
161
+
162
+ When used as a pi package, these settings are persisted in `~/.pi/agent/proxy-config.json` and applied when the extension spawns the proxy.
163
+
164
+ ### Discovering available models
165
+
166
+ List all models the proxy can reach (models with configured credentials):
167
+
168
+ ```bash
169
+ curl http://localhost:4141/v1/models | jq '.data[].id'
170
+ ```
171
+
172
+ Each model includes extended metadata under `x_pi`:
173
+
174
+ ```bash
175
+ curl http://localhost:4141/v1/models/anthropic%2Fclaude-sonnet-4-20250514 | jq '.x_pi'
176
+ # { "api": "anthropic", "reasoning": true, "input": ["text", "image"], ... }
177
+ ```
153
178
 
154
179
  ### Per-request API key override
155
180
 
@@ -174,23 +199,38 @@ curl http://localhost:4141/v1/models \
174
199
  -H "Authorization: Bearer my-secret-token"
175
200
  ```
176
201
 
202
+ ### API compatibility
203
+
204
+ The proxy implements a subset of the [OpenAI Chat Completions API](https://platform.openai.com/docs/api-reference/chat/create). Request and response shapes match the OpenAI specification for supported fields. Unsupported fields are rejected with `422` and an OpenAI-style error body naming the offending parameter.
205
+
206
+ There is no OpenAPI/Swagger spec for the proxy itself. Use the [OpenAI API reference](https://platform.openai.com/docs/api-reference/chat/create) as the primary documentation, noting the supported subset listed in this README.
207
+
177
208
  ## Pi Integration
178
209
 
179
- Install as a pi package to get the `/proxy` command and `--proxy` flag inside pi sessions:
210
+ Install as a pi package to get the `/proxy` command family and `--proxy` flag:
180
211
 
181
212
  ```bash
182
213
  pi install npm:@victor-software-house/pi-openai-proxy
183
214
  ```
184
215
 
185
- ### Start the proxy from inside pi
216
+ ### Command family
186
217
 
187
218
  ```
188
- /proxy start Start the proxy server
189
- /proxy stop Stop the proxy server
190
- /proxy status Show proxy status (default)
191
- /proxy Show proxy status
219
+ /proxy Open the settings panel
220
+ /proxy start Start the proxy server
221
+ /proxy stop Stop the proxy server (session-managed only)
222
+ /proxy status Show proxy status
223
+ /proxy config Open the settings panel
224
+ /proxy show Summarize current configuration
225
+ /proxy path Show config file location
226
+ /proxy reset Restore default settings
227
+ /proxy help Show usage
192
228
  ```
193
229
 
230
+ ### Settings panel
231
+
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
+
194
234
  ### Auto-start with pi
195
235
 
196
236
  ```bash
@@ -1,32 +1,162 @@
1
1
  /**
2
- * Pi extension: /proxy command and --proxy flag.
2
+ * Pi extension: /proxy command family with config panel.
3
3
  *
4
- * Manages the pi-openai-proxy server from inside a pi session.
4
+ * Command family:
5
+ * /proxy Open settings panel
6
+ * /proxy start Start the proxy server
7
+ * /proxy stop Stop the proxy server (session-managed only)
8
+ * /proxy status Show proxy status
9
+ * /proxy config Open settings panel (alias)
10
+ * /proxy show Summarize current config
11
+ * /proxy path Show config file location
12
+ * /proxy reset Restore default settings
13
+ * /proxy help Usage line
5
14
  *
6
- * - /proxy Show status
7
- * - /proxy start Start the proxy server
8
- * - /proxy stop Stop the proxy server (session-managed only)
9
- * - /proxy status Show status
10
- * - --proxy Auto-start on session start
15
+ * Flag:
16
+ * --proxy Auto-start on session start
11
17
  */
12
18
 
13
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
19
+ import {
20
+ getSettingsListTheme,
21
+ type ExtensionAPI,
22
+ type ExtensionCommandContext,
23
+ type ExtensionContext,
24
+ } from "@mariozechner/pi-coding-agent";
25
+ import { Container, SettingsList, Text, type SettingItem } from "@mariozechner/pi-tui";
14
26
  import { spawn, type ChildProcess } from "node:child_process";
15
- import { resolve, dirname } from "node:path";
27
+ import {
28
+ existsSync,
29
+ mkdirSync,
30
+ readFileSync,
31
+ renameSync,
32
+ unlinkSync,
33
+ writeFileSync,
34
+ } from "node:fs";
35
+ import { dirname, resolve } from "node:path";
16
36
  import { fileURLToPath } from "node:url";
17
37
 
18
- export default function proxyExtension(pi: ExtensionAPI) {
19
- let proxyProcess: ChildProcess | undefined;
38
+ // ---------------------------------------------------------------------------
39
+ // Types
40
+ // ---------------------------------------------------------------------------
41
+
42
+ interface ProxyConfig {
43
+ host: string;
44
+ port: number;
45
+ authToken: string;
46
+ remoteImages: boolean;
47
+ maxBodySizeMb: number;
48
+ upstreamTimeoutSec: number;
49
+ }
50
+
51
+ interface RuntimeStatus {
52
+ reachable: boolean;
53
+ models: number;
54
+ managed: boolean;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Defaults and normalization
59
+ // ---------------------------------------------------------------------------
60
+
61
+ const DEFAULT_CONFIG: ProxyConfig = {
62
+ host: "127.0.0.1",
63
+ port: 4141,
64
+ authToken: "",
65
+ remoteImages: false,
66
+ maxBodySizeMb: 50,
67
+ upstreamTimeoutSec: 120,
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
+ };
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
+ // ---------------------------------------------------------------------------
20
129
 
21
- const host = process.env["PI_PROXY_HOST"] ?? "127.0.0.1";
22
- const port = process.env["PI_PROXY_PORT"] ?? "4141";
23
- const proxyUrl = `http://${host}:${port}`;
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
+ // ---------------------------------------------------------------------------
144
+ // Extension
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export default function proxyExtension(pi: ExtensionAPI): void {
148
+ let proxyProcess: ChildProcess | undefined;
149
+ let config = loadConfig();
24
150
 
25
151
  const extensionDir = dirname(fileURLToPath(import.meta.url));
26
152
  const packageRoot = resolve(extensionDir, "..");
27
153
  const proxyEntry = resolve(packageRoot, "dist", "index.mjs");
28
154
 
29
- // --- Flag: --proxy ---
155
+ function proxyUrl(): string {
156
+ return `http://${config.host}:${String(config.port)}`;
157
+ }
158
+
159
+ // --- Flag ---
30
160
 
31
161
  pi.registerFlag("proxy", {
32
162
  description: "Start the OpenAI proxy on session start",
@@ -37,6 +167,7 @@ export default function proxyExtension(pi: ExtensionAPI) {
37
167
  // --- Lifecycle ---
38
168
 
39
169
  pi.on("session_start", async (_event, ctx) => {
170
+ config = loadConfig();
40
171
  if (pi.getFlag("--proxy")) {
41
172
  await startProxy(ctx);
42
173
  } else {
@@ -48,60 +179,84 @@ export default function proxyExtension(pi: ExtensionAPI) {
48
179
  killProxy();
49
180
  });
50
181
 
51
- // --- Command: /proxy ---
182
+ // --- Command family ---
183
+
184
+ const SUBCOMMANDS = ["start", "stop", "status", "config", "show", "path", "reset", "help"];
185
+ const USAGE = "/proxy [start|stop|status|config|show|path|reset|help]";
52
186
 
53
187
  pi.registerCommand("proxy", {
54
- description: "Manage the OpenAI-compatible proxy (start/stop/status)",
188
+ description: "Manage the OpenAI-compatible proxy",
55
189
  getArgumentCompletions: (prefix) => {
56
- const subs = [
57
- { value: "start", label: "Start the proxy server" },
58
- { value: "stop", label: "Stop the proxy server" },
59
- { value: "status", label: "Show proxy status" },
60
- ];
61
- if (prefix.length === 0) return subs;
62
- return subs.filter((s) => s.value.startsWith(prefix));
190
+ const trimmed = prefix.trimStart();
191
+ const matches = SUBCOMMANDS.filter((s) => s.startsWith(trimmed));
192
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
63
193
  },
64
194
  handler: async (args, ctx) => {
65
195
  const sub = args.trim().split(/\s+/)[0] ?? "";
66
196
 
67
197
  switch (sub) {
68
- case "":
69
- case "status":
70
- await showStatus(ctx);
71
- break;
72
198
  case "start":
73
199
  await startProxy(ctx);
74
- break;
200
+ return;
75
201
  case "stop":
76
202
  await stopProxy(ctx);
203
+ return;
204
+ case "status":
205
+ await showStatus(ctx);
206
+ return;
207
+ case "show":
208
+ await showConfig(ctx);
209
+ return;
210
+ case "path":
211
+ ctx.ui.notify(getConfigPath(), "info");
212
+ return;
213
+ case "reset":
214
+ config = { ...DEFAULT_CONFIG };
215
+ saveConfig(config);
216
+ ctx.ui.notify("Proxy settings reset to defaults", "info");
217
+ return;
218
+ case "help":
219
+ ctx.ui.notify(USAGE, "info");
220
+ return;
221
+ case "":
222
+ case "config":
77
223
  break;
78
224
  default:
79
- ctx.ui.notify("/proxy [start|stop|status]", "info");
225
+ ctx.ui.notify(USAGE, "warning");
226
+ return;
80
227
  }
228
+
229
+ // Default: open settings panel
230
+ if (!ctx.hasUI) {
231
+ ctx.ui.notify("/proxy requires interactive mode. Use /proxy show instead.", "warning");
232
+ return;
233
+ }
234
+ await openSettingsPanel(ctx);
81
235
  },
82
236
  });
83
237
 
84
- // --- Proxy management ---
238
+ // --- Proxy process management ---
85
239
 
86
- async function probe(): Promise<{ reachable: boolean; models: number }> {
240
+ async function probe(): Promise<RuntimeStatus> {
241
+ const managed = proxyProcess !== undefined;
87
242
  try {
88
- const res = await fetch(`${proxyUrl}/v1/models`, {
243
+ const res = await fetch(`${proxyUrl()}/v1/models`, {
89
244
  signal: AbortSignal.timeout(2000),
90
245
  });
91
246
  if (res.ok) {
92
247
  const body = (await res.json()) as { data?: unknown[] };
93
- return { reachable: true, models: body.data?.length ?? 0 };
248
+ return { reachable: true, models: body.data?.length ?? 0, managed };
94
249
  }
95
250
  } catch {
96
251
  // not reachable
97
252
  }
98
- return { reachable: false, models: 0 };
253
+ return { reachable: false, models: 0, managed };
99
254
  }
100
255
 
101
256
  async function refreshStatus(ctx: ExtensionContext): Promise<void> {
102
257
  const status = await probe();
103
258
  if (status.reachable) {
104
- ctx.ui.setStatus("proxy", `proxy: ${proxyUrl} (${String(status.models)} models)`);
259
+ ctx.ui.setStatus("proxy", `proxy: ${proxyUrl()} (${String(status.models)} models)`);
105
260
  } else if (proxyProcess !== undefined) {
106
261
  ctx.ui.setStatus("proxy", "proxy: starting...");
107
262
  } else {
@@ -110,10 +265,11 @@ export default function proxyExtension(pi: ExtensionAPI) {
110
265
  }
111
266
 
112
267
  async function startProxy(ctx: ExtensionContext): Promise<void> {
268
+ config = loadConfig();
113
269
  const status = await probe();
114
270
  if (status.reachable) {
115
271
  ctx.ui.notify(
116
- `Proxy already running at ${proxyUrl} (${String(status.models)} models)`,
272
+ `Proxy already running at ${proxyUrl()} (${String(status.models)} models)`,
117
273
  "info",
118
274
  );
119
275
  await refreshStatus(ctx);
@@ -128,10 +284,12 @@ export default function proxyExtension(pi: ExtensionAPI) {
128
284
  ctx.ui.setStatus("proxy", "proxy: starting...");
129
285
 
130
286
  try {
287
+ const proxyEnv = { ...process.env, ...configToEnv(config) };
288
+
131
289
  proxyProcess = spawn("bun", ["run", proxyEntry], {
132
290
  stdio: ["ignore", "pipe", "pipe"],
133
291
  detached: false,
134
- env: { ...process.env },
292
+ env: proxyEnv,
135
293
  });
136
294
 
137
295
  proxyProcess.on("exit", (code) => {
@@ -148,15 +306,14 @@ export default function proxyExtension(pi: ExtensionAPI) {
148
306
  ctx.ui.setStatus("proxy", undefined);
149
307
  });
150
308
 
151
- // Wait for the server to become reachable
152
309
  const ready = await waitForReady(3000);
153
310
  if (ready.reachable) {
154
311
  ctx.ui.notify(
155
- `Proxy started at ${proxyUrl} (${String(ready.models)} models)`,
312
+ `Proxy started at ${proxyUrl()} (${String(ready.models)} models)`,
156
313
  "info",
157
314
  );
158
315
  } else {
159
- ctx.ui.notify(`Proxy spawned but not yet reachable at ${proxyUrl}`, "warning");
316
+ ctx.ui.notify(`Proxy spawned but not yet reachable at ${proxyUrl()}`, "warning");
160
317
  }
161
318
  await refreshStatus(ctx);
162
319
  } catch (err: unknown) {
@@ -177,7 +334,7 @@ export default function proxyExtension(pi: ExtensionAPI) {
177
334
  const status = await probe();
178
335
  if (status.reachable) {
179
336
  ctx.ui.notify(
180
- `Proxy at ${proxyUrl} is running externally (not managed by this session)`,
337
+ `Proxy at ${proxyUrl()} is running externally (not managed by this session)`,
181
338
  "info",
182
339
  );
183
340
  } else {
@@ -187,11 +344,11 @@ export default function proxyExtension(pi: ExtensionAPI) {
187
344
 
188
345
  async function showStatus(ctx: ExtensionContext): Promise<void> {
189
346
  const status = await probe();
190
- const managed = proxyProcess !== undefined ? " (managed)" : " (external)";
347
+ const tag = status.managed ? " (managed)" : " (external)";
191
348
 
192
349
  if (status.reachable) {
193
350
  ctx.ui.notify(
194
- `${proxyUrl}${managed} -- ${String(status.models)} models available`,
351
+ `${proxyUrl()}${tag} -- ${String(status.models)} models available`,
195
352
  "info",
196
353
  );
197
354
  } else {
@@ -200,6 +357,20 @@ export default function proxyExtension(pi: ExtensionAPI) {
200
357
  await refreshStatus(ctx);
201
358
  }
202
359
 
360
+ async function showConfig(ctx: ExtensionContext): Promise<void> {
361
+ config = loadConfig();
362
+ const lines = [
363
+ `host: ${config.host}`,
364
+ `port: ${String(config.port)}`,
365
+ `auth: ${config.authToken.length > 0 ? "enabled" : "disabled"}`,
366
+ `remote images: ${String(config.remoteImages)}`,
367
+ `max body: ${String(config.maxBodySizeMb)} MB`,
368
+ `upstream timeout: ${String(config.upstreamTimeoutSec)}s`,
369
+ ];
370
+ ctx.ui.notify(lines.join(" | "), "info");
371
+ await refreshStatus(ctx);
372
+ }
373
+
203
374
  function killProxy(): void {
204
375
  if (proxyProcess !== undefined) {
205
376
  proxyProcess.kill("SIGTERM");
@@ -207,9 +378,7 @@ export default function proxyExtension(pi: ExtensionAPI) {
207
378
  }
208
379
  }
209
380
 
210
- async function waitForReady(
211
- timeoutMs: number,
212
- ): Promise<{ reachable: boolean; models: number }> {
381
+ async function waitForReady(timeoutMs: number): Promise<RuntimeStatus> {
213
382
  const start = Date.now();
214
383
  const interval = 300;
215
384
  while (Date.now() - start < timeoutMs) {
@@ -217,6 +386,157 @@ export default function proxyExtension(pi: ExtensionAPI) {
217
386
  if (status.reachable) return status;
218
387
  await new Promise((r) => setTimeout(r, interval));
219
388
  }
220
- return { reachable: false, models: 0 };
389
+ return { reachable: false, models: 0, managed: proxyProcess !== undefined };
390
+ }
391
+
392
+ // --- Settings panel ---
393
+
394
+ function buildSettingItems(): SettingItem[] {
395
+ return [
396
+ {
397
+ id: "host",
398
+ label: "Bind address",
399
+ description: "Network interface to listen on (127.0.0.1 = local only, 0.0.0.0 = all)",
400
+ currentValue: config.host,
401
+ values: ["127.0.0.1", "0.0.0.0"],
402
+ },
403
+ {
404
+ id: "port",
405
+ label: "Port",
406
+ description: "HTTP port for the proxy",
407
+ currentValue: String(config.port),
408
+ values: ["4141", "8080", "3000", "9090"],
409
+ },
410
+ {
411
+ id: "authToken",
412
+ label: "Proxy auth",
413
+ description: "Require bearer token for all requests",
414
+ currentValue: config.authToken.length > 0 ? "enabled" : "disabled",
415
+ values: ["disabled", "enabled"],
416
+ },
417
+ {
418
+ id: "remoteImages",
419
+ label: "Remote images",
420
+ description: "Allow remote image URL fetching (security risk if exposed)",
421
+ currentValue: config.remoteImages ? "on" : "off",
422
+ values: ["off", "on"],
423
+ },
424
+ {
425
+ id: "maxBodySizeMb",
426
+ label: "Max body size",
427
+ description: "Maximum request body in MB",
428
+ currentValue: `${String(config.maxBodySizeMb)} MB`,
429
+ values: ["10 MB", "50 MB", "100 MB", "200 MB"],
430
+ },
431
+ {
432
+ id: "upstreamTimeoutSec",
433
+ label: "Upstream timeout",
434
+ description: "Max seconds to wait for upstream provider response",
435
+ currentValue: `${String(config.upstreamTimeoutSec)}s`,
436
+ values: ["30s", "60s", "120s", "300s"],
437
+ },
438
+ ];
439
+ }
440
+
441
+ function applySetting(id: string, value: string): void {
442
+ switch (id) {
443
+ case "host":
444
+ config = { ...config, host: value };
445
+ break;
446
+ case "port":
447
+ config = { ...config, port: clampInt(Number.parseInt(value, 10), 1, 65535, config.port) };
448
+ break;
449
+ case "authToken":
450
+ // Toggle: "enabled" keeps current token or sets placeholder; "disabled" clears
451
+ if (value === "disabled") {
452
+ config = { ...config, authToken: "" };
453
+ } else if (config.authToken.length === 0) {
454
+ // Generate a random token on first enable
455
+ const bytes = new Uint8Array(16);
456
+ crypto.getRandomValues(bytes);
457
+ const token = Array.from(bytes)
458
+ .map((b) => b.toString(16).padStart(2, "0"))
459
+ .join("");
460
+ config = { ...config, authToken: token };
461
+ }
462
+ break;
463
+ case "remoteImages":
464
+ config = { ...config, remoteImages: value === "on" };
465
+ break;
466
+ case "maxBodySizeMb": {
467
+ const mb = Number.parseInt(value, 10);
468
+ if (Number.isFinite(mb) && mb > 0) config = { ...config, maxBodySizeMb: mb };
469
+ break;
470
+ }
471
+ case "upstreamTimeoutSec": {
472
+ const sec = Number.parseInt(value, 10);
473
+ if (Number.isFinite(sec) && sec > 0) config = { ...config, upstreamTimeoutSec: sec };
474
+ break;
475
+ }
476
+ }
477
+ saveConfig(config);
478
+ config = loadConfig(); // read back normalized
479
+ }
480
+
481
+ async function openSettingsPanel(ctx: ExtensionCommandContext): Promise<void> {
482
+ config = loadConfig();
483
+
484
+ await ctx.ui.custom<void>(
485
+ (tui, theme, _kb, done) => {
486
+ function build(): { container: Container; settingsList: SettingsList } {
487
+ const container = new Container();
488
+ container.addChild(new Text(theme.fg("accent", theme.bold("Proxy Settings")), 1, 0));
489
+ container.addChild(new Text(theme.fg("dim", getConfigPath()), 1, 0));
490
+
491
+ const settingsList = new SettingsList(
492
+ buildSettingItems(),
493
+ 10,
494
+ getSettingsListTheme(),
495
+ (id, newValue) => {
496
+ applySetting(id, newValue);
497
+ current = build();
498
+ tui.requestRender();
499
+ },
500
+ () => done(undefined),
501
+ { enableSearch: true },
502
+ );
503
+
504
+ container.addChild(settingsList);
505
+ container.addChild(
506
+ new Text(
507
+ theme.fg("dim", "Esc: close | Arrow keys: navigate | Space: toggle | Restart proxy to apply"),
508
+ 1,
509
+ 0,
510
+ ),
511
+ );
512
+
513
+ return { container, settingsList };
514
+ }
515
+
516
+ let current = build();
517
+
518
+ return {
519
+ render(width: number): string[] {
520
+ return current.container.render(width);
521
+ },
522
+ invalidate(): void {
523
+ current.container.invalidate();
524
+ },
525
+ handleInput(data: string): void {
526
+ current.settingsList.handleInput?.(data);
527
+ tui.requestRender();
528
+ },
529
+ };
530
+ },
531
+ {
532
+ overlay: true,
533
+ overlayOptions: {
534
+ anchor: "center",
535
+ width: 80,
536
+ maxHeight: "85%",
537
+ margin: 1,
538
+ },
539
+ },
540
+ );
221
541
  }
222
542
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-openai-proxy",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Local OpenAI-compatible HTTP proxy built on pi's SDK",
5
5
  "license": "MIT",
6
6
  "author": "Victor Software House",