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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-freeflow 🌊
2
2
 
3
- > **21 free models. Up to 1M context. Zero API keys. Infinite scale via your own relay pool.**
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 Curated Free Models** | 7 OpenCode Zen + 14 KiloCode Gateway models, up to 1M context & 512K output | Ceiling Unlocked | **$0** |
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`, 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
 
33
33
  ---
34
34
 
35
- ### 21 Curated Models, One Command
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 / low / medium / high / xhigh / max` | ✅ |
47
- | `mimo-v2.5-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `low / medium / high` | ✅ |
48
- | `laguna-s-2.1-free` | Poolside | **1M** (1.048.576) | **131K** (131.072) | `low / high / max` | ❌ |
49
- | `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `low / high / max` | ❌ |
50
- | `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `low / high / max` | ❌ |
51
- | `hy3-free` | Tencent Hunyuan | **262K** (262.144) | **262K** (262.144) | `low / high / max` | ❌ |
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 (14 Models), OpenRouter Compatible
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) | **65K** (65.536) | `minimal…xhigh`\* | ✅ |
62
- | `nemotron-3-ultra-550b` (`nvidia/...:free`) | NVIDIA | **1M** (1.000.000) | **65K** (65.536) | `minimal…xhigh`\* | ❌ |
63
- | `nvidia/nemotron-3.5-lightning:free` | NVIDIA | **1M** (1.000.000) | **131K** (131.072) | `minimal…xhigh`\* | ❌ |
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) | **262K** (262.144) | *(none sent)* | ❌ |
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 | **1M** (1.048.576) | **131K** (131.072) | `minimal…xhigh`\* | ❌ |
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 | **128K** (128.000) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
70
- | `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | **256K** (256.000) | **10K** (10.000) | *(non-thinking)* | ❌ |
71
- | `openrouter` (`openrouter/free`) | OpenRouter Free | **200K** (200.000) | **65K** (65.536) | *(non-thinking)* | ✅ |
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 no reasoning parameter today.
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 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
- ```
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
- ```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
- ```
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 snippet below → Deploy → `/freeflow add https://your-project.deno.dev deno-relay-1`
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
- ```ts
215
- const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
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
- 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
- });
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 5MB. Clean, parseable, real-time HTTP lifecycle tracking.
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 and there are no runtime dependencies. Thinking and prompt normalization stay with the host (`pi-ai`).
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 4.6k lines including tests. 16 tests pass, typecheck clean.
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.6.0** (uses `--experimental-strip-types`, no build step)
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 # 16 tests across 2 test files
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 # 21-model catalog definitions
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 # adaptive cooldown on 429/504/socket errors
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 # relay pool persistence
318
- ├── logger.ts # file logger with 5MB rotation
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.** No runtime dependencies. No build step. If it belongs in the host (`pi-ai`), don't add it here.
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.5.0",
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.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,
@@ -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 21 verified models for 0ms instant availability.
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
- 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
  }
@@ -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 21 (host will refresh if needed)
331
+ // No valid cache — return in-memory static 25 (host will refresh if needed)
323
332
  return aliveCatalog;
324
333
  }