pi-freeflow 1.5.0 → 1.6.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.
- package/README.md +38 -66
- package/package.json +2 -3
- package/src/catalog.ts +19 -10
- package/src/commands.ts +308 -146
- package/src/config.ts +24 -2
- package/src/deploy.ts +128 -185
- package/src/health.ts +2 -7
- package/src/index.ts +21 -3
- package/src/logger.ts +52 -24
- package/src/models.ts +49 -54
- package/src/probe.ts +49 -0
- package/src/proxy.ts +150 -57
- package/src/relay-state.ts +380 -69
- package/src/relay.ts +72 -26
- package/src/stream-pipe.ts +307 -271
- package/src/types.ts +4 -2
- package/src/update-checker.ts +21 -5
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Join devs bypassing rate limits with their own relay pools. BYO, add as many as
|
|
|
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
28
|
| **Dumb Proxy That Never Breaks** | `127.0.0.1:28180`, host-normalized, pathname-guarded `/v1/models` | 100% Uptime | **$0** |
|
|
29
|
-
| **Observable Real Logs** | `~/.pi/agent/pi-freeflow.log`,
|
|
29
|
+
| **Observable Real Logs** | `~/.pi/agent/pi-freeflow.log`, 10MB 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.
|
|
32
32
|
|
|
@@ -101,12 +101,17 @@ Manage your relay pool directly from the OMP / Pi terminal:
|
|
|
101
101
|
/freeflow status # View active relay, pool status, and candidates
|
|
102
102
|
/freeflow list # List all relays with real-time health badges (✓ / ⚠️ [cooling])
|
|
103
103
|
/freeflow use <url|index|label> # Switch active relay
|
|
104
|
+
/freeflow url <url> # Set the active relay URL directly
|
|
104
105
|
/freeflow add <url> [label] # Add new relay to the pool
|
|
105
106
|
/freeflow label <index|url> <name># Assign a friendly label to a relay
|
|
106
107
|
/freeflow remove <index|url|label># Remove a relay from the pool
|
|
108
|
+
/freeflow test <index|url|label> # Probe a relay for reachability (HTTP 200 + latency)
|
|
107
109
|
/freeflow on | off | auto # Toggle relay mode (auto = enabled for freeflow)
|
|
108
110
|
/freeflow deploy <platform> # Guided relay deploy: vercel|cloudflare|deno — token in-memory, auto-adds (Vercel 1M/mo recommended)
|
|
109
111
|
/freeflow logs [lines] # Inspect recent proxy logs
|
|
112
|
+
/freeflow trace [req-id] # Tail logs filtered by request correlation ID
|
|
113
|
+
/freeflow refresh # Reload the model catalog from live upstreams
|
|
114
|
+
/freeflow update # Check for and install a package update
|
|
110
115
|
/freeflow debug on | off # Toggle full HTTP lifecycle debug logging
|
|
111
116
|
```
|
|
112
117
|
|
|
@@ -150,26 +155,7 @@ Default ships direct. Add relays via `/freeflow add <url> [label]`.
|
|
|
150
155
|
```bash
|
|
151
156
|
/freeflow deploy cloudflare # prompts token in-memory, auto-adds to pool
|
|
152
157
|
```
|
|
153
|
-
*Manual fallback:* `dash.cloudflare.com` → Workers → Create → Deploy → Edit code → paste
|
|
154
|
-
|
|
155
|
-
```js
|
|
156
|
-
// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
|
|
157
|
-
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
158
|
-
|
|
159
|
-
export default {
|
|
160
|
-
async fetch(req) {
|
|
161
|
-
const target = req.headers.get("x-relay-target");
|
|
162
|
-
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
163
|
-
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400 });
|
|
164
|
-
const cleanTarget = target.replace(/\/$/, "");
|
|
165
|
-
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403 });
|
|
166
|
-
if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400 });
|
|
167
|
-
const headers = new Headers(req.headers);
|
|
168
|
-
headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
|
|
169
|
-
return fetch(cleanTarget + relayPath, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined });
|
|
170
|
-
},
|
|
171
|
-
};
|
|
172
|
-
```
|
|
158
|
+
*Manual fallback:* `dash.cloudflare.com` → Workers → Create → Deploy → Edit code → paste the canonical worker source (see "Canonical worker source" below) → Deploy → `/freeflow add https://your.workers.dev cf-worker-1`
|
|
173
159
|
|
|
174
160
|
**Option B: Vercel Edge Relay (1M req/mo) — Auto Deploy**
|
|
175
161
|
```bash
|
|
@@ -178,30 +164,9 @@ export default {
|
|
|
178
164
|
```
|
|
179
165
|
*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
166
|
|
|
181
|
-
|
|
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
|
-
```
|
|
167
|
+
For `api/relay.js`, use the canonical worker source (see below); `vercel.json` stays:
|
|
202
168
|
|
|
203
169
|
```json
|
|
204
|
-
// vercel.json
|
|
205
170
|
{ "rewrites": [{ "source": "/(.*)", "destination": "/api/relay" }] }
|
|
206
171
|
```
|
|
207
172
|
|
|
@@ -209,22 +174,29 @@ export default async function handler(req) {
|
|
|
209
174
|
```bash
|
|
210
175
|
/freeflow deploy deno # prompts token in-memory, auto-adds to pool
|
|
211
176
|
```
|
|
212
|
-
*Manual fallback:* `dash.deno.com` → New Project → Playground → paste
|
|
177
|
+
*Manual fallback:* `dash.deno.com` → New Project → Playground → paste the canonical worker source (see below) → Deploy → `/freeflow add https://your-project.deno.dev deno-relay-1`
|
|
213
178
|
|
|
214
|
-
|
|
215
|
-
|
|
179
|
+
**Canonical worker source (all platforms)**
|
|
180
|
+
|
|
181
|
+
The relay worker template is generated per deployment by `/freeflow deploy` and lives in [`src/deploy.ts`](src/deploy.ts): one hardened core plus thin Vercel / Cloudflare / Deno wrappers. Every deployment embeds its own shared secret and enforces the target allowlist (`https://opencode.ai`, `https://api.kilo.ai`), SSRF/private-host guard, relay-path validation, and a header denylist — `x-relay-auth` is checked by the worker and never forwarded upstream.
|
|
216
182
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
});
|
|
183
|
+
```js
|
|
184
|
+
// Minimal Cloudflare illustration. Prefer /freeflow deploy: the generated
|
|
185
|
+
// worker (src/deploy.ts) is the signed/hardened source for all three
|
|
186
|
+
// platforms. This example omits the SSRF guard, path validation, and auth.
|
|
187
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
188
|
+
export default {
|
|
189
|
+
async fetch(req) {
|
|
190
|
+
const target = req.headers.get("x-relay-target");
|
|
191
|
+
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
192
|
+
if (!target || !ALLOWED_TARGETS.includes(target.replace(/\/$/, ""))) {
|
|
193
|
+
return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403 });
|
|
194
|
+
}
|
|
195
|
+
const headers = new Headers(req.headers);
|
|
196
|
+
headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
|
|
197
|
+
return fetch(target.replace(/\/$/, "") + relayPath, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined });
|
|
198
|
+
},
|
|
199
|
+
};
|
|
228
200
|
```
|
|
229
201
|
|
|
230
202
|
**Verify your pool:**
|
|
@@ -247,15 +219,15 @@ cat ~/.pi/agent/pi-freeflow.log | tail -n 50
|
|
|
247
219
|
/freeflow debug on
|
|
248
220
|
```
|
|
249
221
|
|
|
250
|
-
Log rotation at
|
|
222
|
+
Log rotation at 10MB. Clean, parseable, real-time HTTP lifecycle tracking.
|
|
251
223
|
|
|
252
224
|
---
|
|
253
225
|
|
|
254
226
|
### Design
|
|
255
227
|
|
|
256
|
-
This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step
|
|
228
|
+
This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step. The only runtime dependency is `undici`, which powers the upstream fetch agent. Thinking and prompt normalization stay with the host (`pi-ai`).
|
|
257
229
|
|
|
258
|
-
Current size: about
|
|
230
|
+
Current size: about 11.3k lines including tests. 226 tests pass, typecheck clean.
|
|
259
231
|
|
|
260
232
|
---
|
|
261
233
|
|
|
@@ -284,7 +256,7 @@ Contributions welcome — bug fixes, new relay platforms, model additions, docs
|
|
|
284
256
|
|
|
285
257
|
#### Prerequisites
|
|
286
258
|
|
|
287
|
-
- **Node.js ≥ 22.
|
|
259
|
+
- **Node.js ≥ 22.19.0** (uses `--experimental-strip-types`, no build step)
|
|
288
260
|
- **pnpm** (package manager)
|
|
289
261
|
|
|
290
262
|
#### Setup & Verify
|
|
@@ -295,7 +267,7 @@ cd pi-freeflow
|
|
|
295
267
|
pnpm install
|
|
296
268
|
|
|
297
269
|
# run all three before opening a PR
|
|
298
|
-
pnpm test #
|
|
270
|
+
pnpm test # 226 tests across 30 test files
|
|
299
271
|
pnpm typecheck # tsc --noEmit, must pass clean
|
|
300
272
|
pnpm smoke # verifies extensions/index.ts loads without crashing
|
|
301
273
|
```
|
|
@@ -310,12 +282,12 @@ src/
|
|
|
310
282
|
├── proxy.ts # local proxy server (127.0.0.1:28180)
|
|
311
283
|
├── relay.ts # relay selection & round-robin
|
|
312
284
|
├── relay-state.ts # relay pool state, health tracking
|
|
313
|
-
├── rate-limiter.ts #
|
|
285
|
+
├── rate-limiter.ts # in-memory sliding rate limiter (200/day, 200/hour)
|
|
314
286
|
├── stream-pipe.ts # SSE stream piping & truncation resilience
|
|
315
287
|
├── commands.ts # /freeflow CLI subcommands
|
|
316
288
|
├── deploy.ts # guided relay deploy (vercel/cloudflare/deno)
|
|
317
|
-
├── config.ts #
|
|
318
|
-
├── logger.ts # file logger with
|
|
289
|
+
├── config.ts # constants, whitelists, paths, and runtime settings
|
|
290
|
+
├── logger.ts # file logger with 10MB rotation
|
|
319
291
|
└── types.ts # shared type definitions
|
|
320
292
|
extensions/
|
|
321
293
|
└── index.ts # OMP/Pi extension manifest
|
|
@@ -325,7 +297,7 @@ test/
|
|
|
325
297
|
|
|
326
298
|
#### Guidelines
|
|
327
299
|
|
|
328
|
-
- **Stay thin.**
|
|
300
|
+
- **Stay thin.** One runtime dependency (`undici`), no build step. If it belongs in the host (`pi-ai`), don't add it here.
|
|
329
301
|
- **Test what you touch.** Every `src/*.ts` has a matching `test/*.test.ts`. Add or update tests for your change.
|
|
330
302
|
- **Keep model IDs clean.** Slash-free, colon-free aliases for CLI compatibility. See existing patterns in `models.ts`.
|
|
331
303
|
- **One concern per PR.** Bug fix? One PR. New relay platform? Separate PR. Easier to review, faster to merge.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.0",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"homepage": "https://github.com/trefeon/pi-freeflow#readme",
|
|
26
26
|
"engines": {
|
|
27
|
-
"node": ">=22.
|
|
27
|
+
"node": ">=22.19.0"
|
|
28
28
|
},
|
|
29
29
|
"omp": {
|
|
30
30
|
"extensions": [
|
|
@@ -52,7 +52,6 @@
|
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@changesets/cli": "^2.27.0",
|
|
55
|
-
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
56
55
|
"@types/node": "^22.13.9",
|
|
57
56
|
"typescript": "^5.8.2",
|
|
58
57
|
"vitepress": "^1.6.4"
|
package/src/catalog.ts
CHANGED
|
@@ -10,6 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
import {
|
|
11
11
|
CATALOG_CACHE_FILE,
|
|
12
12
|
CATALOG_CACHE_TTL_MS,
|
|
13
|
+
CATALOG_REFRESH_TIMEOUT_MS,
|
|
13
14
|
KILO_CHAT_URL,
|
|
14
15
|
OPENCODE_API_URL,
|
|
15
16
|
opencodeHeaders,
|
|
@@ -17,12 +18,8 @@ import {
|
|
|
17
18
|
import { log, logDebug, logWarn } from "./logger.ts";
|
|
18
19
|
import {
|
|
19
20
|
ALL_MODELS,
|
|
20
|
-
KILO_MODELS,
|
|
21
21
|
KILO_MODEL_IDS,
|
|
22
|
-
KNOWN_MODELS,
|
|
23
22
|
MODEL_MAP,
|
|
24
|
-
OPENCODE_MODELS,
|
|
25
|
-
getAllRegisteredModels,
|
|
26
23
|
} from "./models.ts";
|
|
27
24
|
import type {
|
|
28
25
|
CatalogCacheData,
|
|
@@ -178,9 +175,10 @@ export function readCatalogCache(): CatalogCacheData | null {
|
|
|
178
175
|
}
|
|
179
176
|
const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
|
|
180
177
|
const data = JSON.parse(raw) as CatalogCacheData;
|
|
181
|
-
if (Array.isArray(data.models)) {
|
|
182
|
-
|
|
178
|
+
if (!Array.isArray(data.models)) {
|
|
179
|
+
return null;
|
|
183
180
|
}
|
|
181
|
+
data.models = data.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
184
182
|
if (Date.now() - data.timestamp < CATALOG_CACHE_TTL_MS) {
|
|
185
183
|
return data;
|
|
186
184
|
}
|
|
@@ -249,7 +247,12 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
249
247
|
if (cachedEtag) {
|
|
250
248
|
headers["If-None-Match"] = cachedEtag;
|
|
251
249
|
}
|
|
252
|
-
const res = await fetch(`${OPENCODE_API_URL}/models`, {
|
|
250
|
+
const res = await fetch(`${OPENCODE_API_URL}/models`, {
|
|
251
|
+
headers,
|
|
252
|
+
// A hung upstream must not freeze /freeflow refresh: abort after
|
|
253
|
+
// CATALOG_REFRESH_TIMEOUT_MS and fall back to cache below.
|
|
254
|
+
signal: AbortSignal.timeout(CATALOG_REFRESH_TIMEOUT_MS),
|
|
255
|
+
});
|
|
253
256
|
if (res.status === 304) {
|
|
254
257
|
// Not modified — skip merge, extend timestamp to avoid tight loop
|
|
255
258
|
if (staleForEtag && Array.isArray(staleForEtag.models)) {
|
|
@@ -291,14 +294,20 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
291
294
|
return aliveCatalog;
|
|
292
295
|
}
|
|
293
296
|
} catch (err) {
|
|
294
|
-
|
|
297
|
+
if ((err as Error)?.name === "AbortError") {
|
|
298
|
+
logWarn("Catalog refresh timed out — using cached/static fallback", {
|
|
299
|
+
timeoutMs: CATALOG_REFRESH_TIMEOUT_MS,
|
|
300
|
+
});
|
|
301
|
+
} else {
|
|
302
|
+
logDebug("Conditional catalog fetch failed, falling back to cache", { error: String(err) });
|
|
303
|
+
}
|
|
295
304
|
}
|
|
296
305
|
}
|
|
297
306
|
|
|
298
307
|
// Stale cache still better than empty — return it without network (filtered)
|
|
299
308
|
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
300
309
|
const filtered = disk.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
301
|
-
if (filtered.length >=
|
|
310
|
+
if (filtered.length >= ALL_MODELS.length) {
|
|
302
311
|
aliveCatalog = filtered;
|
|
303
312
|
return aliveCatalog;
|
|
304
313
|
}
|
|
@@ -310,7 +319,7 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
310
319
|
const stale = JSON.parse(raw) as CatalogCacheData;
|
|
311
320
|
if (Array.isArray(stale.models) && stale.models.length > 0) {
|
|
312
321
|
const filtered = stale.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
313
|
-
if (filtered.length >=
|
|
322
|
+
if (filtered.length >= ALL_MODELS.length) {
|
|
314
323
|
aliveCatalog = filtered;
|
|
315
324
|
return aliveCatalog;
|
|
316
325
|
}
|