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 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`, 5MB auto-rotation, real-time debug toggle | Observable | **$0** |
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 snippet below → Deploy → `/freeflow add https://your.workers.dev cf-worker-1`
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
- ```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
- ```
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 snippet below → Deploy → `/freeflow add https://your-project.deno.dev deno-relay-1`
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
- ```ts
215
- const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
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
- Deno.serve(async (req) => {
218
- const target = req.headers.get("x-relay-target");
219
- const relayPath = req.headers.get("x-relay-path") || "/";
220
- if (!target || !ALLOWED_TARGETS.includes(target.replace(/\/$/, ""))) {
221
- return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403 });
222
- }
223
- const headers = new Headers(req.headers);
224
- headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
225
- const res = await fetch(target.replace(/\/$/, "") + relayPath, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined });
226
- return new Response(res.body, { status: res.status, headers: res.headers });
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 5MB. Clean, parseable, real-time HTTP lifecycle tracking.
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 and there are no runtime dependencies. Thinking and prompt normalization stay with the host (`pi-ai`).
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 4.6k lines including tests. 16 tests pass, typecheck clean.
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.6.0** (uses `--experimental-strip-types`, no build step)
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 # 16 tests across 2 test files
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 # adaptive cooldown on 429/504/socket errors
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 # relay pool persistence
318
- ├── logger.ts # file logger with 5MB rotation
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.** No runtime dependencies. No build step. If it belongs in the host (`pi-ai`), don't add it here.
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.5.0",
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.6.0"
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
- data.models = data.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
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`, { headers });
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
- logDebug("Conditional catalog fetch failed, falling back to cache", { error: String(err) });
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 >= 21) {
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 >= 21) {
322
+ if (filtered.length >= ALL_MODELS.length) {
314
323
  aliveCatalog = filtered;
315
324
  return aliveCatalog;
316
325
  }