pi-freeflow 1.5.0 → 1.7.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 +62 -86
- package/package.json +2 -3
- package/src/catalog.ts +21 -12
- 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 +22 -4
- package/src/logger.ts +52 -24
- package/src/models.ts +139 -67
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-freeflow 🌊
|
|
2
2
|
|
|
3
|
-
> **
|
|
3
|
+
> **25 free models. Up to 1M context. Zero API keys. Infinite scale via your own relay pool.**
|
|
4
4
|
|
|
5
5
|
Thin by design: model list + dumb relay + log. Host `pi-ai` owns thinking, normalization & provider magic. We just make it free, fast, and unbreakable.
|
|
6
6
|
|
|
@@ -18,7 +18,7 @@ Join devs bypassing rate limits with their own relay pools. BYO, add as many as
|
|
|
18
18
|
|
|
19
19
|
| Feature | Description | Value | Cost |
|
|
20
20
|
| :--- | :--- | :--- | :--- |
|
|
21
|
-
| **
|
|
21
|
+
| **25 Curated Free Models** | 7 OpenCode Zen + 18 KiloCode Gateway models, up to 1M context & 512K output | Ceiling Unlocked | **$0** |
|
|
22
22
|
| **BYO Relay Pool** | Round-robin load balancing across your Cloudflare Workers & Vercel Edges | Zero Rate Limits | **$0** (your free tiers) |
|
|
23
23
|
| **Adaptive Health & Error Detection** | Auto-cooldown on 429 rate limits, 504 timeouts, and socket drops | 0ms Wasted Latency | **$0** |
|
|
24
24
|
| **Stream Truncation Resilience** | Stateful SSE terminal tracking (`response.failed` / `response.incomplete` injection) | Zero Host Crashes | **$0** |
|
|
@@ -26,13 +26,13 @@ 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
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
-
###
|
|
35
|
+
### 25 Curated Models, One Command
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
/model → freeflow → pick
|
|
@@ -43,35 +43,39 @@ Optimized for deep reasoning, long-horizon coding & autonomous agentic workflows
|
|
|
43
43
|
|
|
44
44
|
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
45
45
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
46
|
-
| `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal
|
|
47
|
-
| `mimo-v2.5-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `
|
|
48
|
-
| `laguna-s-2.1-free` | Poolside | **
|
|
49
|
-
| `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `
|
|
50
|
-
| `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `
|
|
51
|
-
| `hy3-free` | Tencent Hunyuan | **262K** (262.144) | **
|
|
46
|
+
| `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
47
|
+
| `mimo-v2.5-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
|
|
48
|
+
| `laguna-s-2.1-free` | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal … xhigh` | ❌ |
|
|
49
|
+
| `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal … xhigh` | ❌ |
|
|
50
|
+
| `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal … xhigh` | ❌ |
|
|
51
|
+
| `hy3-free` | Tencent Hunyuan | **262K** (262.144) | **128K** (128.000) | `minimal … xhigh` | ❌ |
|
|
52
52
|
| `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
|
|
53
53
|
|
|
54
|
-
#### KiloCode Gateway (
|
|
54
|
+
#### KiloCode Gateway (18 Models), OpenRouter Compatible
|
|
55
55
|
Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI aliases supported.
|
|
56
56
|
|
|
57
57
|
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
58
58
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
59
59
|
| `dots-3-note-preview` (`dots-studio/...:free`) | Dots Studio | **512K** (512.000) | **512K** (512.000) | `minimal…xhigh`\* | ✅ |
|
|
60
60
|
| `step-3.7-flash` (`stepfun/...:free`) | StepFun | **262K** (262.144) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
|
|
61
|
-
| `nemotron-3-nano-omni` (`nvidia/...:free`) | NVIDIA | **256K** (256.000) | **
|
|
62
|
-
| `nemotron-3-ultra-550b` (`nvidia/...:free`) | NVIDIA | **1M** (1.000.000) | **
|
|
63
|
-
| `nvidia/nemotron-3.5-lightning:free` | NVIDIA | **1M** (1.000.000) | **
|
|
61
|
+
| `nemotron-3-nano-omni` (`nvidia/...:free`) | NVIDIA | **256K** (256.000) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
|
|
62
|
+
| `nemotron-3-ultra-550b` (`nvidia/...:free`) | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal…xhigh`\* | ❌ |
|
|
63
|
+
| `nvidia/nemotron-3.5-lightning:free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
|
|
64
64
|
| `nemotron-3-super` (`nvidia/...:free`) | NVIDIA | **262K** (262.144) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
|
|
65
|
-
| `hy3:free` (`tencent/hy3:free`) | Tencent Hunyuan | **262K** (262.144) | **
|
|
65
|
+
| `hy3:free` (`tencent/hy3:free`) | Tencent Hunyuan | **262K** (262.144) | **128K** (128.000) | `minimal…xhigh`\* | ❌ |
|
|
66
66
|
| `north-mini-code` (`cohere/...:free`) | Cohere | **256K** (256.000) | **64K** (64.000) | `minimal…xhigh`\* | ❌ |
|
|
67
|
-
| `laguna-s-2.1:free` (`poolside/...:free`) | Poolside | **
|
|
67
|
+
| `laguna-s-2.1:free` (`poolside/...:free`) | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
68
68
|
| `laguna-xs-2.1:free` (`poolside/...:free`) | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
69
|
-
| `lfm-2.5` (`liquid/lfm-2.5-2.6b:free`) | Liquid AI | **
|
|
70
|
-
| `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | **256K** (256.000) | **10K** (10.000) |
|
|
71
|
-
| `openrouter` (`openrouter/free`) | OpenRouter Free | **200K** (200.000) | **65K** (65.536) |
|
|
69
|
+
| `lfm-2.5` (`liquid/lfm-2.5-2.6b:free`) | Liquid AI | **65K** (65.536) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
70
|
+
| `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | **256K** (256.000) | **10K** (10.000) | `minimal…xhigh`\* | ❌ |
|
|
71
|
+
| `openrouter` (`openrouter/free`) | OpenRouter Free | **200K** (200.000) | **65K** (65.536) | `minimal…xhigh`\* | ✅ |
|
|
72
72
|
| `content-safety` (`nvidia/...:free`) | NVIDIA | **128K** (128.000) | **8K** (8.192) | ❌ *(non-thinking)* | ✅ |
|
|
73
|
+
| `longcat-2.0` (`meituan/longcat-2.0-free`) | Meituan | **1M** (1.048.756) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
|
|
74
|
+
| `minimax-m2.7` (`minimax/minimax-m2.7:free`) | MiniMax | **196K** (196.608) | **196K** (196.608) | `minimal…xhigh`\* | ❌ |
|
|
75
|
+
| `minimax-m3` (`minimax/minimax-m3:free`) | MiniMax | **1M** (1.048.576) | **512K** (524.288) | `minimal…xhigh`\* | ❌ |
|
|
76
|
+
| `ling-3.0-flash-fin` (`inclusionai/ling-3.0-flash-fin:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
73
77
|
|
|
74
|
-
\* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model. hy3 accepts
|
|
78
|
+
\* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model. (Verified live 2026-08-29: hy3 accepts flat `reasoning_effort`/nested `reasoning` and returns thinking — README previously said otherwise.) MiMo collapses `minimal→low` and `xhigh→high` upstream, so its selector shows 5 labels but only 3 distinct effort values.
|
|
75
79
|
|
|
76
80
|
---
|
|
77
81
|
|
|
@@ -101,12 +105,17 @@ Manage your relay pool directly from the OMP / Pi terminal:
|
|
|
101
105
|
/freeflow status # View active relay, pool status, and candidates
|
|
102
106
|
/freeflow list # List all relays with real-time health badges (✓ / ⚠️ [cooling])
|
|
103
107
|
/freeflow use <url|index|label> # Switch active relay
|
|
108
|
+
/freeflow url <url> # Set the active relay URL directly
|
|
104
109
|
/freeflow add <url> [label] # Add new relay to the pool
|
|
105
110
|
/freeflow label <index|url> <name># Assign a friendly label to a relay
|
|
106
111
|
/freeflow remove <index|url|label># Remove a relay from the pool
|
|
112
|
+
/freeflow test <index|url|label> # Probe a relay for reachability (HTTP 200 + latency)
|
|
107
113
|
/freeflow on | off | auto # Toggle relay mode (auto = enabled for freeflow)
|
|
108
114
|
/freeflow deploy <platform> # Guided relay deploy: vercel|cloudflare|deno — token in-memory, auto-adds (Vercel 1M/mo recommended)
|
|
109
115
|
/freeflow logs [lines] # Inspect recent proxy logs
|
|
116
|
+
/freeflow trace [req-id] # Tail logs filtered by request correlation ID
|
|
117
|
+
/freeflow refresh # Reload the model catalog from live upstreams
|
|
118
|
+
/freeflow update # Check for and install a package update
|
|
110
119
|
/freeflow debug on | off # Toggle full HTTP lifecycle debug logging
|
|
111
120
|
```
|
|
112
121
|
|
|
@@ -150,26 +159,7 @@ Default ships direct. Add relays via `/freeflow add <url> [label]`.
|
|
|
150
159
|
```bash
|
|
151
160
|
/freeflow deploy cloudflare # prompts token in-memory, auto-adds to pool
|
|
152
161
|
```
|
|
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
|
-
```
|
|
162
|
+
*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
163
|
|
|
174
164
|
**Option B: Vercel Edge Relay (1M req/mo) — Auto Deploy**
|
|
175
165
|
```bash
|
|
@@ -178,30 +168,9 @@ export default {
|
|
|
178
168
|
```
|
|
179
169
|
*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
170
|
|
|
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
|
-
```
|
|
171
|
+
For `api/relay.js`, use the canonical worker source (see below); `vercel.json` stays:
|
|
202
172
|
|
|
203
173
|
```json
|
|
204
|
-
// vercel.json
|
|
205
174
|
{ "rewrites": [{ "source": "/(.*)", "destination": "/api/relay" }] }
|
|
206
175
|
```
|
|
207
176
|
|
|
@@ -209,22 +178,29 @@ export default async function handler(req) {
|
|
|
209
178
|
```bash
|
|
210
179
|
/freeflow deploy deno # prompts token in-memory, auto-adds to pool
|
|
211
180
|
```
|
|
212
|
-
*Manual fallback:* `dash.deno.com` → New Project → Playground → paste
|
|
181
|
+
*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
182
|
|
|
214
|
-
|
|
215
|
-
|
|
183
|
+
**Canonical worker source (all platforms)**
|
|
184
|
+
|
|
185
|
+
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
186
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
});
|
|
187
|
+
```js
|
|
188
|
+
// Minimal Cloudflare illustration. Prefer /freeflow deploy: the generated
|
|
189
|
+
// worker (src/deploy.ts) is the signed/hardened source for all three
|
|
190
|
+
// platforms. This example omits the SSRF guard, path validation, and auth.
|
|
191
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
192
|
+
export default {
|
|
193
|
+
async fetch(req) {
|
|
194
|
+
const target = req.headers.get("x-relay-target");
|
|
195
|
+
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
196
|
+
if (!target || !ALLOWED_TARGETS.includes(target.replace(/\/$/, ""))) {
|
|
197
|
+
return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403 });
|
|
198
|
+
}
|
|
199
|
+
const headers = new Headers(req.headers);
|
|
200
|
+
headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
|
|
201
|
+
return fetch(target.replace(/\/$/, "") + relayPath, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined });
|
|
202
|
+
},
|
|
203
|
+
};
|
|
228
204
|
```
|
|
229
205
|
|
|
230
206
|
**Verify your pool:**
|
|
@@ -247,15 +223,15 @@ cat ~/.pi/agent/pi-freeflow.log | tail -n 50
|
|
|
247
223
|
/freeflow debug on
|
|
248
224
|
```
|
|
249
225
|
|
|
250
|
-
Log rotation at
|
|
226
|
+
Log rotation at 10MB. Clean, parseable, real-time HTTP lifecycle tracking.
|
|
251
227
|
|
|
252
228
|
---
|
|
253
229
|
|
|
254
230
|
### Design
|
|
255
231
|
|
|
256
|
-
This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step
|
|
232
|
+
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
233
|
|
|
258
|
-
Current size: about
|
|
234
|
+
Current size: about 11.3k lines including tests. 228 tests pass, typecheck clean.
|
|
259
235
|
|
|
260
236
|
---
|
|
261
237
|
|
|
@@ -284,7 +260,7 @@ Contributions welcome — bug fixes, new relay platforms, model additions, docs
|
|
|
284
260
|
|
|
285
261
|
#### Prerequisites
|
|
286
262
|
|
|
287
|
-
- **Node.js ≥ 22.
|
|
263
|
+
- **Node.js ≥ 22.19.0** (uses `--experimental-strip-types`, no build step)
|
|
288
264
|
- **pnpm** (package manager)
|
|
289
265
|
|
|
290
266
|
#### Setup & Verify
|
|
@@ -295,7 +271,7 @@ cd pi-freeflow
|
|
|
295
271
|
pnpm install
|
|
296
272
|
|
|
297
273
|
# run all three before opening a PR
|
|
298
|
-
pnpm test #
|
|
274
|
+
pnpm test # 228 tests across 30 test files
|
|
299
275
|
pnpm typecheck # tsc --noEmit, must pass clean
|
|
300
276
|
pnpm smoke # verifies extensions/index.ts loads without crashing
|
|
301
277
|
```
|
|
@@ -305,17 +281,17 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
|
|
|
305
281
|
```
|
|
306
282
|
src/
|
|
307
283
|
├── index.ts # extension entry, lifecycle hooks
|
|
308
|
-
├── models.ts #
|
|
284
|
+
├── models.ts # 25-model catalog definitions
|
|
309
285
|
├── catalog.ts # model catalog cache (24h disk)
|
|
310
286
|
├── proxy.ts # local proxy server (127.0.0.1:28180)
|
|
311
287
|
├── relay.ts # relay selection & round-robin
|
|
312
288
|
├── relay-state.ts # relay pool state, health tracking
|
|
313
|
-
├── rate-limiter.ts #
|
|
289
|
+
├── rate-limiter.ts # in-memory sliding rate limiter (200/day, 200/hour)
|
|
314
290
|
├── stream-pipe.ts # SSE stream piping & truncation resilience
|
|
315
291
|
├── commands.ts # /freeflow CLI subcommands
|
|
316
292
|
├── deploy.ts # guided relay deploy (vercel/cloudflare/deno)
|
|
317
|
-
├── config.ts #
|
|
318
|
-
├── logger.ts # file logger with
|
|
293
|
+
├── config.ts # constants, whitelists, paths, and runtime settings
|
|
294
|
+
├── logger.ts # file logger with 10MB rotation
|
|
319
295
|
└── types.ts # shared type definitions
|
|
320
296
|
extensions/
|
|
321
297
|
└── index.ts # OMP/Pi extension manifest
|
|
@@ -325,7 +301,7 @@ test/
|
|
|
325
301
|
|
|
326
302
|
#### Guidelines
|
|
327
303
|
|
|
328
|
-
- **Stay thin.**
|
|
304
|
+
- **Stay thin.** One runtime dependency (`undici`), no build step. If it belongs in the host (`pi-ai`), don't add it here.
|
|
329
305
|
- **Test what you touch.** Every `src/*.ts` has a matching `test/*.test.ts`. Add or update tests for your change.
|
|
330
306
|
- **Keep model IDs clean.** Slash-free, colon-free aliases for CLI compatibility. See existing patterns in `models.ts`.
|
|
331
307
|
- **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.7.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,
|
|
@@ -37,7 +34,7 @@ import type {
|
|
|
37
34
|
export const DEAD_MODEL_IDS = new Set<string>(["deepseek-v4-flash-free", "x-preview-f-free"]);
|
|
38
35
|
/**
|
|
39
36
|
* In-memory cache of currently active/available free models.
|
|
40
|
-
* Initialized with all
|
|
37
|
+
* Initialized with all 25 verified models for 0ms instant availability.
|
|
41
38
|
*/
|
|
42
39
|
let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
|
|
43
40
|
...m,
|
|
@@ -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
|
}
|
|
@@ -319,6 +328,6 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
|
|
|
319
328
|
} catch (err) {
|
|
320
329
|
logDebug("Failed reading stale catalog cache", { error: String(err) });
|
|
321
330
|
}
|
|
322
|
-
// No valid cache — return in-memory static
|
|
331
|
+
// No valid cache — return in-memory static 25 (host will refresh if needed)
|
|
323
332
|
return aliveCatalog;
|
|
324
333
|
}
|