pi-freeflow 1.4.8 → 1.4.10

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
@@ -25,7 +25,7 @@ Join devs bypassing rate limits with their own relay pools. BYO, add as many as
25
25
  | **Smart Model Aliasing** | Clean slash-free & colon-free CLI model names compatible with thinking selectors | DX Optimized | **$0** |
26
26
  | **Auto-Enabled on Session** | Relay stays enabled in `auto` mode on session start and model switch | Zero Friction | **$0** |
27
27
  | **Interactive CLI Management** | 10+ `/freeflow` subcommands (`status`, `list`, `use`, `add`, `label`, `remove`, `deploy`, `logs`, `debug`) | Full Control | **$0** |
28
- | **Dumb Proxy That Never Breaks** | `127.0.0.1:18080`, host-normalized, pathname-guarded `/v1/models` | 100% Uptime | **$0** |
28
+ | **Dumb Proxy That Never Breaks** | `127.0.0.1:28180`, host-normalized, pathname-guarded `/v1/models` | 100% Uptime | **$0** |
29
29
  | **Observable Real Logs** | `~/.pi/agent/pi-freeflow.log`, 5MB auto-rotation, real-time debug toggle | Observable | **$0** |
30
30
 
31
31
  Philosophy: **Thin by design.** We only ship model list + relay proxy + log. Host owns thinking & normalization.
@@ -78,7 +78,7 @@ Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI alia
78
78
  ### How It Works: BYO Relays, Zero Rate Limits
79
79
 
80
80
  ```
81
- You → 127.0.0.1:18080 (dumb proxy, host-normalized) → x-relay-target → N egress IPs (your pool) → opencode.ai / api.kilo.ai
81
+ You → 127.0.0.1:28180 (dumb proxy, host-normalized) → x-relay-target → N egress IPs (your pool) → opencode.ai / api.kilo.ai
82
82
  ↑ host already normalized thinking → proxy just forwards
83
83
  ```
84
84
 
@@ -176,6 +176,34 @@ export default {
176
176
  /freeflow deploy vercel # prompts token in-memory, auto-adds to pool
177
177
  # or shorthand: /freeflow deploy
178
178
  ```
179
+ *Manual fallback:* Push 2 files (`api/relay.js` + `vercel.json`) to GitHub $\to$ Import on `vercel.com` $\to$ `/freeflow add https://your.vercel.app vercel-relay-1`
180
+
181
+ ```js
182
+ // api/relay.js
183
+ const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
184
+ export const config = { runtime: "edge" };
185
+ export default async function handler(req) {
186
+ const target = req.headers.get("x-relay-target");
187
+ const relayPath = req.headers.get("x-relay-path") || "/";
188
+ if (!target || !ALLOWED_TARGETS.includes(target.replace(/\/$/, ""))) {
189
+ return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403 });
190
+ }
191
+ const headers = new Headers(req.headers);
192
+ headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
193
+ const res = await fetch(target.replace(/\/$/, "") + relayPath, {
194
+ method: req.method,
195
+ headers,
196
+ body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined,
197
+ duplex: "half",
198
+ });
199
+ return new Response(res.body, { status: res.status, headers: res.headers });
200
+ }
201
+ ```
202
+
203
+ ```json
204
+ // vercel.json
205
+ { "rewrites": [{ "source": "/(.*)", "destination": "/api/relay" }] }
206
+ ```
179
207
 
180
208
  **Option C: Deno Deploy (100k req/day) — Auto Deploy**
181
209
  ```bash
@@ -279,7 +307,7 @@ src/
279
307
  ├── index.ts # extension entry, lifecycle hooks
280
308
  ├── models.ts # 21-model catalog definitions
281
309
  ├── catalog.ts # model catalog cache (24h disk)
282
- ├── proxy.ts # local proxy server (127.0.0.1:18080)
310
+ ├── proxy.ts # local proxy server (127.0.0.1:28180)
283
311
  ├── relay.ts # relay selection & round-robin
284
312
  ├── relay-state.ts # relay pool state, health tracking
285
313
  ├── rate-limiter.ts # adaptive cooldown on 429/504/socket errors
package/package.json CHANGED
@@ -1,55 +1,55 @@
1
1
  {
2
- "name": "pi-freeflow",
3
- "type": "module",
4
- "version": "1.4.8",
5
- "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
- "main": "extensions/index.ts",
7
- "types": "src/index.ts",
8
- "keywords": [
9
- "pi-package",
10
- "pi-extension",
11
- "oh-my-pi",
12
- "omp",
13
- "free-models",
14
- "opencode",
15
- "kilocode",
16
- "ai-models",
17
- "relay"
18
- ],
19
- "author": "trefeon",
20
- "license": "MIT",
21
- "repository": {
22
- "type": "git",
23
- "url": "git+https://github.com/trefeon/pi-freeflow.git"
24
- },
25
- "homepage": "https://github.com/trefeon/pi-freeflow#readme",
26
- "engines": {
27
- "node": ">=22.6.0"
28
- },
29
- "omp": {
30
- "extensions": [
31
- "./extensions"
32
- ]
33
- },
34
- "pi": {
35
- "extensions": [
36
- "./extensions"
37
- ]
38
- },
39
- "files": [
40
- "extensions",
41
- "src",
42
- "README.md",
43
- "LICENSE"
44
- ],
45
- "scripts": {
46
- "test": "node --experimental-strip-types --test test/**/*.test.ts",
47
- "typecheck": "tsc --noEmit",
48
- "smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\""
49
- },
50
- "devDependencies": {
51
- "@earendil-works/pi-coding-agent": "^0.84.3",
52
- "@types/node": "^22.13.9",
53
- "typescript": "^5.8.2"
54
- }
2
+ "name": "pi-freeflow",
3
+ "type": "module",
4
+ "version": "1.4.10",
5
+ "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
+ "main": "extensions/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "oh-my-pi",
12
+ "omp",
13
+ "free-models",
14
+ "opencode",
15
+ "kilocode",
16
+ "ai-models",
17
+ "relay"
18
+ ],
19
+ "author": "trefeon",
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/trefeon/pi-freeflow.git"
24
+ },
25
+ "homepage": "https://github.com/trefeon/pi-freeflow#readme",
26
+ "engines": {
27
+ "node": ">=22.6.0"
28
+ },
29
+ "omp": {
30
+ "extensions": [
31
+ "./extensions"
32
+ ]
33
+ },
34
+ "pi": {
35
+ "extensions": [
36
+ "./extensions"
37
+ ]
38
+ },
39
+ "files": [
40
+ "extensions",
41
+ "src",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "scripts": {
46
+ "test": "node --experimental-strip-types --test --test-concurrency=1 test/**/*.test.ts",
47
+ "typecheck": "tsc --noEmit",
48
+ "smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\""
49
+ },
50
+ "devDependencies": {
51
+ "@earendil-works/pi-coding-agent": "^0.84.3",
52
+ "@types/node": "^22.13.9",
53
+ "typescript": "^5.8.2"
54
+ }
55
55
  }
package/src/config.ts CHANGED
@@ -14,7 +14,7 @@ export const KILO_CHAT_URL = "https://api.kilo.ai/api/gateway/chat/completions";
14
14
  export const OPENCODE_API_URL = `${UPSTREAM_OPENCODE}/v1`;
15
15
 
16
16
  // ── Network & Server defaults ───────────────────────────────────────
17
- export const DEFAULT_PORT = 18080;
17
+ export const DEFAULT_PORT = 28180;
18
18
  export const HOST = "127.0.0.1";
19
19
  export const DEFAULT_HOST = "127.0.0.1";
20
20
 
package/src/deploy.ts CHANGED
@@ -14,18 +14,61 @@ import { log, logError } from "./logger.ts";
14
14
  */
15
15
  export const VERCEL_RELAY_WORKER = `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
16
16
  const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
17
+ const resolveRelayTarget = function(target, relayPath) {
18
+ let targetUrl;
19
+ try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
20
+ if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\") !== -1 || relayPath.charAt(0) !== "/") {
21
+ return { ok: false, status: 403, reason: "forbidden x-relay-path" };
22
+ }
23
+ let finalUrl;
24
+ try { finalUrl = new URL(relayPath, targetUrl); } catch { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; }
25
+ if (finalUrl.hostname !== targetUrl.hostname || finalUrl.protocol !== targetUrl.protocol || finalUrl.port !== targetUrl.port || finalUrl.username || finalUrl.password) {
26
+ return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" };
27
+ }
28
+ return { ok: true, url: finalUrl.toString() };
29
+ };
30
+ const isPrivateHostname = function(h) {
31
+ if (!h) return true
32
+ let host = String(h).trim().toLowerCase().replace(/^\[|\]$/g, "")
33
+ if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
34
+ if (!host) return true
35
+ if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true
36
+ if (host.startsWith("::")) return true
37
+ const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
38
+ if (v4) {
39
+ const a = Number(v4[1])
40
+ const b = Number(v4[2])
41
+ if (a === 0 || a === 10 || a === 127) return true
42
+ if (a === 169 && b === 254) return true
43
+ if (a === 192 && b === 168) return true
44
+ if (a === 172 && b >= 16 && b <= 31) return true
45
+ if (a === 100 && b >= 64 && b <= 127) return true
46
+ return false
47
+ }
48
+ if (host.includes(":")) {
49
+ if (host.startsWith("fc") || host.startsWith("fd")) return true
50
+ if (/^fe[89ab]/.test(host)) return true
51
+ return false
52
+ }
53
+ return false
54
+ };
17
55
  export const config = { runtime: "edge" };
18
56
  export default async function handler(req) {
19
57
  const target = req.headers.get("x-relay-target");
20
- const relayPath = req.headers.get("x-relay-path") || "/";
21
58
  if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
22
- const cleanTarget = target.replace(/\\/$/, "");
59
+ let targetUrl;
60
+ try { targetUrl = new URL(target); } catch { return new Response(JSON.stringify({ error: "invalid x-relay-target" }), { status: 400, headers: { "content-type": "application/json" } }); }
61
+ if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") return new Response(JSON.stringify({ error: "forbidden x-relay-target protocol" }), { status: 403, headers: { "content-type": "application/json" } });
62
+ if (targetUrl.username || targetUrl.password) return new Response(JSON.stringify({ error: "forbidden x-relay-target (embedded credentials)" }), { status: 403, headers: { "content-type": "application/json" } });
63
+ if (isPrivateHostname(targetUrl.hostname)) return new Response(JSON.stringify({ error: "forbidden x-relay-target (private/loopback host)" }), { status: 403, headers: { "content-type": "application/json" } });
64
+ const cleanTarget = target.replace(/\/$/, "");
23
65
  if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
24
- if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400, headers: { "content-type": "application/json" } });
25
- const targetUrl = cleanTarget + relayPath;
66
+ const relayPath = req.headers.get("x-relay-path") || "/";
67
+ const resolved = resolveRelayTarget(target, relayPath);
68
+ if (!resolved.ok) return new Response(JSON.stringify({ error: resolved.reason }), { status: resolved.status, headers: { "content-type": "application/json" } });
26
69
  const headers = new Headers(req.headers);
27
- headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
28
- const response = await fetch(targetUrl, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half" });
70
+ ["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path", "x-relay-auth"].forEach((h) => headers.delete(h));
71
+ const response = await fetch(resolved.url, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half" });
29
72
  return new Response(response.body, { status: response.status, headers: response.headers });
30
73
  }`;
31
74
 
@@ -142,23 +185,68 @@ export type DeployPlatform = "vercel" | "cloudflare" | "deno";
142
185
 
143
186
  /**
144
187
  * Module Worker relay deployed to Cloudflare Workers.
145
- * Same whitelist contract as the Vercel Edge relay, without Vercel's
146
- * `config` export or undici-only `duplex` flag (plain body passthrough).
188
+ * Same whitelist contract as the Vercel Edge relay, with SSRF guard, relay-path resolver, and streaming duplex.
147
189
  */
148
190
  export const CLOUDFLARE_RELAY_WORKER = `// Only the 2 upstreams this relay talks to. Anything else = open proxy abuse.
149
191
  const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
192
+ const resolveRelayTarget = function(target, relayPath) {
193
+ let targetUrl;
194
+ try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
195
+ if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\") !== -1 || relayPath.charAt(0) !== "/") {
196
+ return { ok: false, status: 403, reason: "forbidden x-relay-path" };
197
+ }
198
+ let finalUrl;
199
+ try { finalUrl = new URL(relayPath, targetUrl); } catch { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; }
200
+ if (finalUrl.hostname !== targetUrl.hostname || finalUrl.protocol !== targetUrl.protocol || finalUrl.port !== targetUrl.port || finalUrl.username || finalUrl.password) {
201
+ return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" };
202
+ }
203
+ return { ok: true, url: finalUrl.toString() };
204
+ };
205
+ const isPrivateHostname = function(h) {
206
+ if (!h) return true
207
+ let host = String(h).trim().toLowerCase().replace(/^\[|\]$/g, "")
208
+ if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
209
+ if (!host) return true
210
+ if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true
211
+ if (host.startsWith("::")) return true
212
+ const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
213
+ if (v4) {
214
+ const a = Number(v4[1])
215
+ const b = Number(v4[2])
216
+ if (a === 0 || a === 10 || a === 127) return true
217
+ if (a === 169 && b === 254) return true
218
+ if (a === 192 && b === 168) return true
219
+ if (a === 172 && b >= 16 && b <= 31) return true
220
+ if (a === 100 && b >= 64 && b <= 127) return true
221
+ return false
222
+ }
223
+ if (host.includes(":")) {
224
+ if (host.startsWith("fc") || host.startsWith("fd")) return true
225
+ if (/^fe[89ab]/.test(host)) return true
226
+ return false
227
+ }
228
+ return false
229
+ };
150
230
  export default {
151
231
  async fetch(request) {
152
232
  const target = request.headers.get("x-relay-target");
153
- const relayPath = request.headers.get("x-relay-path") || "/";
154
233
  if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
155
- const cleanTarget = target.replace(/\\/$/, "");
234
+ let targetUrl;
235
+ try { targetUrl = new URL(target); } catch { return new Response(JSON.stringify({ error: "invalid x-relay-target" }), { status: 400, headers: { "content-type": "application/json" } }); }
236
+ if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") return new Response(JSON.stringify({ error: "forbidden x-relay-target protocol" }), { status: 403, headers: { "content-type": "application/json" } });
237
+ if (targetUrl.username || targetUrl.password) return new Response(JSON.stringify({ error: "forbidden x-relay-target (embedded credentials)" }), { status: 403, headers: { "content-type": "application/json" } });
238
+ if (isPrivateHostname(targetUrl.hostname)) return new Response(JSON.stringify({ error: "forbidden x-relay-target (private/loopback host)" }), { status: 403, headers: { "content-type": "application/json" } });
239
+ const cleanTarget = target.replace(/\/$/, "");
156
240
  if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
157
- if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400, headers: { "content-type": "application/json" } });
241
+ const relayPath = request.headers.get("x-relay-path") || "/";
242
+ const resolved = resolveRelayTarget(target, relayPath);
243
+ if (!resolved.ok) return new Response(JSON.stringify({ error: resolved.reason }), { status: resolved.status, headers: { "content-type": "application/json" } });
158
244
  const headers = new Headers(request.headers);
159
- headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
245
+ ["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path", "x-relay-auth"].forEach((h) => headers.delete(h));
160
246
  try {
161
- const response = await fetch(cleanTarget + relayPath, { method: request.method, headers, body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined });
247
+ const init = { method: request.method, headers };
248
+ if (request.method !== "GET" && request.method !== "HEAD") { init.body = request.body; init.duplex = "half"; }
249
+ const response = await fetch(resolved.url, init);
162
250
  return new Response(response.body, { status: response.status, headers: response.headers });
163
251
  } catch (error) {
164
252
  return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
@@ -168,21 +256,67 @@ export default {
168
256
 
169
257
  /**
170
258
  * Relay script deployed to Deno Deploy (Deno.serve variant).
171
- * Same whitelist contract; plain streaming passthrough, no duplex flag.
259
+ * Same whitelist contract as the Vercel Edge relay, with SSRF guard and relay-path resolver.
172
260
  */
173
261
  export const DENO_RELAY_SCRIPT = `// Only the 2 upstreams this relay talks to. Anything else = open proxy abuse.
174
262
  const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
263
+ const resolveRelayTarget = function(target, relayPath) {
264
+ let targetUrl;
265
+ try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
266
+ if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\") !== -1 || relayPath.charAt(0) !== "/") {
267
+ return { ok: false, status: 403, reason: "forbidden x-relay-path" };
268
+ }
269
+ let finalUrl;
270
+ try { finalUrl = new URL(relayPath, targetUrl); } catch { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; }
271
+ if (finalUrl.hostname !== targetUrl.hostname || finalUrl.protocol !== targetUrl.protocol || finalUrl.port !== targetUrl.port || finalUrl.username || finalUrl.password) {
272
+ return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" };
273
+ }
274
+ return { ok: true, url: finalUrl.toString() };
275
+ };
276
+ const isPrivateHostname = function(h) {
277
+ if (!h) return true
278
+ let host = String(h).trim().toLowerCase().replace(/^\[|\]$/g, "")
279
+ if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
280
+ if (!host) return true
281
+ if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true
282
+ if (host.startsWith("::")) return true
283
+ const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
284
+ if (v4) {
285
+ const a = Number(v4[1])
286
+ const b = Number(v4[2])
287
+ if (a === 0 || a === 10 || a === 127) return true
288
+ if (a === 169 && b === 254) return true
289
+ if (a === 192 && b === 168) return true
290
+ if (a === 172 && b >= 16 && b <= 31) return true
291
+ if (a === 100 && b >= 64 && b <= 127) return true
292
+ return false
293
+ }
294
+ if (host.includes(":")) {
295
+ if (host.startsWith("fc") || host.startsWith("fd")) return true
296
+ if (/^fe[89ab]/.test(host)) return true
297
+ return false
298
+ }
299
+ return false
300
+ };
175
301
  Deno.serve(async (request) => {
176
302
  const target = request.headers.get("x-relay-target");
177
- const relayPath = request.headers.get("x-relay-path") || "/";
178
303
  if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
179
- const cleanTarget = target.replace(/\\/$/, "");
304
+ let targetUrl;
305
+ try { targetUrl = new URL(target); } catch { return new Response(JSON.stringify({ error: "invalid x-relay-target" }), { status: 400, headers: { "content-type": "application/json" } }); }
306
+ if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") return new Response(JSON.stringify({ error: "forbidden x-relay-target protocol" }), { status: 403, headers: { "content-type": "application/json" } });
307
+ if (targetUrl.username || targetUrl.password) return new Response(JSON.stringify({ error: "forbidden x-relay-target (embedded credentials)" }), { status: 403, headers: { "content-type": "application/json" } });
308
+ if (isPrivateHostname(targetUrl.hostname)) return new Response(JSON.stringify({ error: "forbidden x-relay-target (private/loopback host)" }), { status: 403, headers: { "content-type": "application/json" } });
309
+ const cleanTarget = target.replace(/\/$/, "");
180
310
  if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
181
- if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400, headers: { "content-type": "application/json" } });
311
+ const relayPath = request.headers.get("x-relay-path") || "/";
312
+ const resolved = resolveRelayTarget(target, relayPath);
313
+ if (!resolved.ok) return new Response(JSON.stringify({ error: resolved.reason }), { status: resolved.status, headers: { "content-type": "application/json" } });
182
314
  const headers = new Headers(request.headers);
183
- headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
315
+ ["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path", "x-relay-auth"].forEach((h) => headers.delete(h));
184
316
  try {
185
- const response = await fetch(cleanTarget + relayPath, { method: request.method, headers, body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined });
317
+ const init = { method: request.method, headers };
318
+ if (request.method !== "GET" && request.method !== "HEAD") { init.body = request.body; init.duplex = "half"; }
319
+ const response = await fetch(resolved.url, init);
186
320
  return new Response(response.body, { status: response.status, headers: response.headers });
187
321
  } catch (error) {
188
322
  return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
3
3
  *
4
4
  * Provides access to 21 free models (7 OpenCode Zen + 14 KiloCode Gateway) with:
5
- * - Single-port daemon reuse on 18080 across concurrent subagents
5
+ * - Single-port daemon reuse on 28180 across concurrent subagents
6
6
  * - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
7
7
  * - 0ms instant startup with verified static catalog and background live health checks
8
8
  * - Per-model thinking/reasoning translation and streaming SSE pass-through
package/src/proxy.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Single-port local HTTP proxy and dynamic upstream router for pi-freeflow
3
3
  *
4
- * Provides loopback proxying on port 18080 (shared across parent and subagents),
4
+ * Provides loopback proxying on port 28180 (shared across parent and subagents),
5
5
  * intelligent routing to OpenCode Zen and KiloCode Gateway, and failover support.
6
6
  */
7
7
 
@@ -111,9 +111,8 @@ export async function isProxyAlive(port: number): Promise<boolean> {
111
111
 
112
112
  /**
113
113
  * Start the local HTTP proxy daemon.
114
- *
115
- * Implements master/worker single-port reuse: if port 18080 is already held by a live
116
- * parent or sibling OMP session, resolves immediately with { server: null, port: 18080 }.
114
+ * Implements master/worker single-port reuse: if port 28180 is already held by a live
115
+ * parent or sibling OMP session, resolves immediately with { server: null, port: 28180 }.
117
116
  */
118
117
  export function startProxy(
119
118
  overridePort?: number,
@@ -175,7 +175,8 @@ export function pipeUpstreamStream(
175
175
  if (!res.headersSent) {
176
176
  res.writeHead(502, { "content-type": "application/json" });
177
177
  } else {
178
- ensureTerminalEvent(true, errorMsg);
178
+ const isSubstantial = totalChunks > 50 && totalBytes > 100 * 1024;
179
+ ensureTerminalEvent(!isSubstantial, errorMsg, true);
179
180
  }
180
181
  if (!res.writableEnded) {
181
182
  res.end();
@@ -223,9 +224,14 @@ export function pipeUpstreamStream(
223
224
  // is not at fault; still give the host a terminal event.
224
225
  ensureTerminalEvent(false, "stream interrupted by client", false);
225
226
  } else {
226
- // Upstream socket died mid-stream with no error event:
227
- // genuine upstream truncation penalize the relay.
228
- ensureTerminalEvent(true, "stream closed prematurely");
227
+ // Upstream socket died mid-stream with no error event.
228
+ // For muse-spark large payloads: raxtant 514KB failed but feoni 802KB
229
+ // succeeded with same 2.6MB in — so this is edge-specific, not pure
230
+ // provider token limit. Keep penalize=true to rotate failing relay,
231
+ // but inject incomplete (not failed) for substantial to avoid alarming
232
+ // stream_error. Small premature (<50 chunks) stays failed+penalize.
233
+ const isSubstantial = totalChunks > 50 && totalBytes > 100 * 1024;
234
+ ensureTerminalEvent(!isSubstantial, "stream closed prematurely", true);
229
235
  }
230
236
  }
231
237
  if (!res.writableEnded) res.end();