@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 +58 -18
- package/extensions/proxy.ts +368 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,21 +135,46 @@ openrouter/anthropic/claude-sonnet-4-20250514
|
|
|
135
135
|
|
|
136
136
|
## Configuration
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
### What comes from pi
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
- **Custom models**: `~/.pi/agent/models.json`
|
|
140
|
+
The proxy reads two files from pi's configuration directory (`~/.pi/agent/`):
|
|
142
141
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
| Variable | Default | Description |
|
|
142
|
+
| File | Managed by | What the proxy uses |
|
|
146
143
|
|---|---|---|
|
|
147
|
-
| `
|
|
148
|
-
| `
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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
|
-
###
|
|
216
|
+
### Command family
|
|
186
217
|
|
|
187
218
|
```
|
|
188
|
-
/proxy
|
|
189
|
-
/proxy
|
|
190
|
-
/proxy
|
|
191
|
-
/proxy
|
|
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
|
package/extensions/proxy.ts
CHANGED
|
@@ -1,32 +1,162 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pi extension: /proxy command
|
|
2
|
+
* Pi extension: /proxy command family with config panel.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
188
|
+
description: "Manage the OpenAI-compatible proxy",
|
|
55
189
|
getArgumentCompletions: (prefix) => {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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(
|
|
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<
|
|
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:
|
|
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
|
|
347
|
+
const tag = status.managed ? " (managed)" : " (external)";
|
|
191
348
|
|
|
192
349
|
if (status.reachable) {
|
|
193
350
|
ctx.ui.notify(
|
|
194
|
-
`${proxyUrl}${
|
|
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
|
}
|