pi-freeflow 1.3.7 → 1.3.9

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
@@ -23,14 +23,15 @@ You love Pi. You love free models. You hate:
23
23
  - `reasoning_effort: max` rejected and thinking silently disabled
24
24
  - Paying for 1M context you should get for free
25
25
 
26
- **FreeFlow fixes all three — in 17 files, 102KB.**
26
+ **FreeFlow fixes all three — in 17 files, 115KB.**
27
27
 
28
28
  ### Features
29
29
 
30
30
  | What You Get | Value | Cost |
31
31
  | :--- | :--- | :--- |
32
- | **23 Curated Free Models** — 9 OpenCode Zen + 14 Kilo, 66K-1M context, vision-ready | Unlimited | **$0** |
32
+ | **23 Curated Free Models** — 9 OpenCode Zen + 14 Kilo Gateway, 66K-1M context, vision-ready | Unlimited | **$0** |
33
33
  | **BYO Relay Pool** — Cloudflare Workers + Vercel Edge, round-robin, seamless `429 → next` | Auto-scale | **$0** (your free tiers) |
34
+ | **Adaptive Health & Error Detection** — Auto-cooldown on 429/504/socket drops, instant roll to healthy relays | Self-healing | **$0** |
34
35
  | **Dumb Proxy That Never Breaks** — `127.0.0.1:18080`, host-normalized, pathname-guarded `/v1/models` | 100% uptime | **$0** |
35
36
  | **Real Logs** — `~/.pi/agent/pi-freeflow.log`, 5MB rotate, debug toggle | Observable | **$0** |
36
37
 
@@ -45,26 +46,41 @@ Philosophy: **Thin by design.** We only ship model list + relay proxy + log. Hos
45
46
  ```
46
47
 
47
48
  #### OpenCode Zen (9) — Responses & Chat API
48
- Best for long-context reasoning. All vision-ready models marked.
49
-
50
- | Model | Context | Max Output | Thinking | Vision |
51
- | :--- | :--- | :--- | :--- | :--- |
52
- | `claude-sonnet-4.5-free` / `muse-spark-1.2-contributor-free` | 1M | 131K | `minimal → max` | ✅ |
53
- | `deepseek-v4-flash-free` | 1M | 384K | `minimal max` | |
54
- | `grok-code-fast-1-preview-f-free` / `x-preview-f-free` | 1M | 131K | `low / high / max` | ✅ |
55
- | `mimo-v2.5-free` | 1M | 131K | `low / medium / high` | |
56
- | `minimax-m2.1-free` / `laguna-s-2.1-free` | 1M | 131K | `minimal → max` | ❌ |
57
- | `nemotron-3.5-lightning-free` | 1M | 262K | `minimal → max` | ❌ |
58
- | `nemotron-3-ultra-free` | 1M | 128K | `minimal → max` | ❌ |
59
- | `qwen3-coder-480b-free` / `hy3-free` | 262K | 128K | `minimal → max` | ❌ |
60
- | `big-pickle` | 200K | 32K | `high / max` | ❌ |
49
+ Best for long-context reasoning & autonomous agentic tasks.
50
+
51
+ | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
52
+ | :--- | :--- | :--- | :--- | :--- | :--- |
53
+ | `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | 1M | 131K | `minimal → max` | ✅ |
54
+ | `x-preview-f-free` | Ox Alpha | 1M | 131K | `low / high / max` | |
55
+ | `mimo-v2.5-free` | Xiaomi MiMo | 1M | 131K | `low / medium / high` | ✅ |
56
+ | `deepseek-v4-flash-free` | DeepSeek | 1M | 384K | `minimal max` | |
57
+ | `laguna-s-2.1-free` | Poolside | 1M | 131K | `minimal → max` | ❌ |
58
+ | `nemotron-3.5-lightning-free` | NVIDIA | 1M | 262K | `minimal → max` | ❌ |
59
+ | `nemotron-3-ultra-free` | NVIDIA | 1M | 128K | `minimal → max` | ❌ |
60
+ | `hy3-free` | Tencent Hunyuan | 262K | 262K | `minimal → max` | ❌ |
61
+ | `big-pickle` | Big Pickle | 200K | 32K | `high / max` | ❌ |
61
62
 
62
63
  #### Kilo Gateway (14) — OpenRouter Compatible
63
- Zero-config, `Bearer kilo-free`.
64
+ Keyless access with `Bearer kilo-free`. Short slash-free & colon-free CLI aliases supported.
65
+
66
+ | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
67
+ | :--- | :--- | :--- | :--- | :--- | :--- |
68
+ | `dots-3-note-preview` (`dots-studio/...:free`) | Dots Studio | 512K | 512K | `openrouter` (auto) | ✅ |
69
+ | `step-3.7-flash` (`stepfun/...:free`) | StepFun | 262K | 262K | `low / medium / high` | ✅ |
70
+ | `nemotron-3-nano-omni` (`nvidia/...:free`) | NVIDIA | 256K | 65K | `openrouter` (auto) | ✅ |
71
+ | `nemotron-3-ultra-550b` (`nvidia/...:free`) | NVIDIA | 1M | 65K | `openrouter` (auto) | ❌ |
72
+ | `nemotron-3.5-lightning` (`nvidia/...:free`) | NVIDIA | 1M | 131K | `openrouter` (auto) | ❌ |
73
+ | `nemotron-3-super` (`nvidia/...:free`) | NVIDIA | 262K | 262K | `openrouter` (auto) | ❌ |
74
+ | `hy3` (`tencent/hy3:free`) | Tencent Hunyuan | 262K | 262K | `openrouter` (auto) | ❌ |
75
+ | `north-mini-code` (`cohere/...:free`) | Cohere | 256K | 64K | `openrouter` (auto) | ❌ |
76
+ | `laguna-s-2.1` (`poolside/...:free`) | Poolside | 1M | 131K | `openrouter` (auto) | ❌ |
77
+ | `laguna-xs-2.1` (`poolside/...:free`) | Poolside | 262K | 32K | `openrouter` (auto) | ❌ |
78
+ | `lfm-2.5` (`liquid/lfm-2.5-2.6b:free`) | Liquid AI | 128K | 32K | `openrouter` (auto) | ❌ |
79
+ | `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | 256K | 10K | ❌ | ❌ |
80
+ | `openrouter` (`openrouter/free`) | OpenRouter Free | 200K | 65K | ❌ | ✅ |
81
+ | `content-safety` (`nvidia/...:free`) | NVIDIA | 128K | 8K | ❌ | ✅ |
64
82
 
65
- `dots-3-note-preview:free` (512K), `step-3.7-flash:free` (262K), `nemotron-3-nano-omni:free` (256K), `nemotron-3-ultra-550b:free` (1M), `nemotron-3-super:free` (262K), `hy3:free` (262K), `north-mini-code:free` (256K), `minimax-m2.1/xs:free` (262K), `lfm-2.5:free` (66K), `kilo-auto/free`, `openrouter/free`, `content-safety:free` + 2 more.
66
-
67
- > All `off → null` (hidden). Host decides token budgets, not us.
83
+ ---
68
84
 
69
85
  ### How It Works — The Magic: BYO Relays, Zero Rate Limits
70
86
 
@@ -74,10 +90,11 @@ You → 127.0.0.1:18080 (dumb proxy, host-normalized) → x-relay-target → N e
74
90
  ```
75
91
 
76
92
  - **Round-robin per request** — 10 parallel subagents hit N different egress IPs (your pool size). No thundering herd.
93
+ - **Adaptive Health & Error Cooldown** — Relays hitting 429, 504, or network socket drops enter automatic cooldown (30-90s) and move behind healthy candidates.
77
94
  - **Seamless 429 roll** — `429 / 502 / 503 / 504` → instant roll to next relay, never 429 to agent.
78
95
  - **Exhausted? Direct fallback** — If all relays are 429 (global limit), transparent direct fetch. Host shows rate limit only when truly global.
79
96
  - **No live catalog fetch** — 24h `DISK_CACHE_ONLY`, never fetch from subagent → no `Unable to connect`.
80
- - **Real log (v1.3.1 thin):** `candidates:N relay-A 429 rolling relay-B 200 in 1.2s → 98 chunks 663KB done`
97
+ - **Stream Truncation Resilience** Detects SSE terminal events; handles premature stream closure with clean termination.
81
98
 
82
99
  You bring the relays (free tiers). We bring the rolling.
83
100
 
@@ -103,10 +120,10 @@ pi install npm:pi-freeflow
103
120
 
104
121
  ```bash
105
122
  omp
106
- /model → freeflow → claude-sonnet-4.5 (1M) → max
123
+ /model → freeflow → muse-spark-1.2-contributor-free (1M) → max
107
124
 
108
125
  # or CLI
109
- omp -p --model freeflow/claude-sonnet-4.5-contributor-free "build me a SaaS"
126
+ omp -p --model freeflow/muse-spark-1.2-contributor-free "build me a SaaS"
110
127
  ```
111
128
 
112
129
  #### 3. (Optional but 🔥) Add Your Free Relays
@@ -131,6 +148,7 @@ omp → /freeflow use https://your.workers.dev
131
148
  **Verify your pool:**
132
149
  ```bash
133
150
  /freeflow status # relay-A 1/N (ON) → candidates:N
151
+ /freeflow list # lists all relays with health status
134
152
  /freeflow logs # tail -25
135
153
  cat ~/.pi/agent/pi-freeflow.log | tail -n 20
136
154
  ```
@@ -158,7 +176,7 @@ Log rotation at 5MB. Clean, parseable, real-time.
158
176
  | 400 loops N× relays | Narrow 429/5xx only, 400 surfaces instantly |
159
177
  | `GET /v1/models?foo` leaks paid | Pathname-guarded |
160
178
 
161
- **Result: <500 LOC core, 29 tests PASS, typecheck PASS.**
179
+ **Result: <500 LOC core, 39 tests PASS, typecheck PASS.**
162
180
 
163
181
  ### FAQ
164
182
 
@@ -195,5 +213,5 @@ MIT © trefeon
195
213
 
196
214
  Start now:
197
215
  ```bash
198
- omp plugin install pi-freeflow && omp --model freeflow/claude-sonnet-4.5-contributor-free --thinking max "make it sell"
216
+ omp plugin install pi-freeflow && omp --model freeflow/muse-spark-1.2-contributor-free --thinking max "make it sell"
199
217
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.3.7",
4
+ "version": "1.3.9",
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",
package/src/models.ts CHANGED
@@ -87,7 +87,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
87
87
  name: "Hy3 (262K)",
88
88
  reasoning: true,
89
89
  contextWindow: 262_144,
90
- maxTokens: 128_000,
90
+ maxTokens: 262_144,
91
91
  input: ["text"],
92
92
  thinkingLevelMap: {
93
93
  off: null,
@@ -220,7 +220,7 @@ export const KILO_MODELS: ModelDef[] = [
220
220
  name: "Nemotron 3.5 Lightning (Kilo)",
221
221
  reasoning: true,
222
222
  contextWindow: 1_000_000,
223
- maxTokens: 65_536,
223
+ maxTokens: 131_072,
224
224
  input: ["text"],
225
225
  thinkingFormat: "openrouter",
226
226
  },
@@ -238,9 +238,8 @@ export const KILO_MODELS: ModelDef[] = [
238
238
  name: "Tencent Hy3 (Kilo)",
239
239
  reasoning: true,
240
240
  contextWindow: 262_144,
241
- maxTokens: 128_000,
241
+ maxTokens: 262_144,
242
242
  input: ["text"],
243
- thinkingFormat: "openrouter",
244
243
  },
245
244
  {
246
245
  id: "cohere/north-mini-code:free",
@@ -255,8 +254,8 @@ export const KILO_MODELS: ModelDef[] = [
255
254
  id: "poolside/laguna-s-2.1:free",
256
255
  name: "Laguna S 2.1 (Kilo)",
257
256
  reasoning: true,
258
- contextWindow: 262_144,
259
- maxTokens: 32_768,
257
+ contextWindow: 1_048_576,
258
+ maxTokens: 131_072,
260
259
  input: ["text"],
261
260
  thinkingFormat: "openrouter",
262
261
  },
@@ -273,8 +272,8 @@ export const KILO_MODELS: ModelDef[] = [
273
272
  id: "liquid/lfm-2.5-2.6b:free",
274
273
  name: "Liquid LFM 2.5",
275
274
  reasoning: true,
276
- contextWindow: 65_536,
277
- maxTokens: 8_192,
275
+ contextWindow: 128_000,
276
+ maxTokens: 32_768,
278
277
  input: ["text"],
279
278
  thinkingFormat: "openrouter",
280
279
  },
@@ -292,7 +291,7 @@ export const KILO_MODELS: ModelDef[] = [
292
291
  reasoning: false,
293
292
  contextWindow: 200_000,
294
293
  maxTokens: 65_536,
295
- input: ["text"],
294
+ input: ["text", "image"],
296
295
  },
297
296
  {
298
297
  id: "nvidia/nemotron-3.5-content-safety:free",
@@ -300,7 +299,7 @@ export const KILO_MODELS: ModelDef[] = [
300
299
  reasoning: false,
301
300
  contextWindow: 128_000,
302
301
  maxTokens: 8_192,
303
- input: ["text"],
302
+ input: ["text", "image"],
304
303
  },
305
304
  ];
306
305
 
package/src/proxy.ts CHANGED
@@ -273,6 +273,7 @@ export function startProxy(
273
273
  res,
274
274
  req,
275
275
  reqId,
276
+ relayPreview.url,
276
277
  );
277
278
  } else {
278
279
  const data = await response.text();
@@ -328,6 +329,7 @@ export function startProxy(
328
329
  res,
329
330
  req,
330
331
  reqId,
332
+ relayState.url,
331
333
  );
332
334
  } else {
333
335
  const data = await response.text();
@@ -392,16 +394,20 @@ export function startProxy(
392
394
  }
393
395
  outHeaders["x-content-type-options"] = "nosniff";
394
396
  res.writeHead(upstream.statusCode ?? 502, outHeaders);
395
- upstream.on("error", (streamErr) => {
396
- log(
397
- "error",
398
- "upstream stream error in direct proxy",
399
- { error: String(streamErr) },
400
- reqId,
401
- );
402
- if (!res.writableEnded) res.end();
403
- });
404
- upstream.pipe(res);
397
+ if (isStream) {
398
+ pipeUpstreamStream(upstream, res, req, reqId, "direct");
399
+ } else {
400
+ upstream.on("error", (streamErr) => {
401
+ log(
402
+ "error",
403
+ "upstream stream error in direct proxy",
404
+ { error: String(streamErr) },
405
+ reqId,
406
+ );
407
+ if (!res.writableEnded) res.end();
408
+ });
409
+ upstream.pipe(res);
410
+ }
405
411
  },
406
412
  );
407
413
 
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
9
9
  import type * as http from "node:http";
10
10
  import type { Readable } from "node:stream";
11
11
  import { isDebugEnabled, log } from "./logger.ts";
12
-
12
+ import { markRelayFailure } from "./relay-state.ts";
13
13
  /**
14
14
  * Pipes an upstream readable stream to a client HTTP response.
15
15
  *
@@ -23,6 +23,7 @@ export function pipeUpstreamStream(
23
23
  res: http.ServerResponse,
24
24
  req: http.IncomingMessage,
25
25
  reqId?: string,
26
+ relayUrl?: string,
26
27
  ): void {
27
28
  const rid = reqId || randomUUID().slice(0, 8);
28
29
  let totalChunks = 0;
@@ -31,6 +32,8 @@ export function pipeUpstreamStream(
31
32
  let thinkingBytes = 0;
32
33
  let firstChunkAt: number | null = null;
33
34
  const startAt = Date.now();
35
+ const isResponsesApi = req.url?.includes("/responses") ?? false;
36
+ let hasTerminalEvent = false;
34
37
 
35
38
  const sniffThinking = (chunk: Buffer | string): boolean => {
36
39
  const s =
@@ -47,12 +50,53 @@ export function pipeUpstreamStream(
47
50
  );
48
51
  };
49
52
 
53
+ const checkTerminalEvent = (s: string): boolean => {
54
+ return (
55
+ s.includes("response.completed") ||
56
+ s.includes("response.done") ||
57
+ s.includes("response.failed") ||
58
+ s.includes("response.incomplete") ||
59
+ s.includes("[DONE]")
60
+ );
61
+ };
62
+
63
+ const ensureTerminalEvent = (isError = false, errorMsg?: string) => {
64
+ if (hasTerminalEvent || res.writableEnded) return;
65
+ if (relayUrl && relayUrl !== "direct") {
66
+ markRelayFailure(relayUrl, 0, errorMsg || "stream truncated prematurely");
67
+ }
68
+ if (isResponsesApi && totalChunks > 0) {
69
+ try {
70
+ if (isError) {
71
+ res.write(
72
+ `\nevent: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"stream_error","message":${JSON.stringify(errorMsg || "Upstream stream disconnected unexpectedly")}}}}\n\n`,
73
+ );
74
+ } else {
75
+ res.write(
76
+ `\nevent: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"cancelled"}}}\n\n`,
77
+ );
78
+ }
79
+ hasTerminalEvent = true;
80
+ log(
81
+ "warn",
82
+ `injected synthetic response.${isError ? "failed" : "incomplete"} for prematurely truncated stream`,
83
+ { totalChunks, totalBytes, isError, errorMsg },
84
+ rid,
85
+ );
86
+ } catch {}
87
+ } else if (!isResponsesApi && totalChunks > 0) {
88
+ try {
89
+ res.write("\ndata: [DONE]\n\n");
90
+ hasTerminalEvent = true;
91
+ } catch {}
92
+ }
93
+ };
94
+
50
95
  try {
51
96
  if (typeof res.flushHeaders === "function") {
52
97
  res.flushHeaders();
53
98
  }
54
99
  } catch {}
55
-
56
100
  nodeStream.on("data", (chunk: Buffer | string) => {
57
101
  try {
58
102
  if (firstChunkAt === null) {
@@ -82,6 +126,14 @@ export function pipeUpstreamStream(
82
126
  }
83
127
  }
84
128
 
129
+ const strPreview =
130
+ typeof chunk === "string"
131
+ ? chunk
132
+ : chunk.toString("utf8", 0, Math.min(chunk.length, 2000));
133
+ if (!hasTerminalEvent && checkTerminalEvent(strPreview)) {
134
+ hasTerminalEvent = true;
135
+ }
136
+
85
137
  res.write(chunk);
86
138
  const maybeFlush = res as unknown as { flush?: () => void };
87
139
  if (typeof maybeFlush.flush === "function") {
@@ -91,15 +143,18 @@ export function pipeUpstreamStream(
91
143
  });
92
144
 
93
145
  nodeStream.on("error", (e: unknown) => {
146
+ const errorMsg = (e as Error)?.message || String(e);
94
147
  log(
95
148
  "error",
96
149
  "upstream stream error",
97
- { error: String(e), totalChunks, thinkingChunks },
150
+ { error: errorMsg, totalChunks, thinkingChunks, hasTerminalEvent },
98
151
  rid,
99
152
  );
100
153
  try {
101
154
  if (!res.headersSent) {
102
155
  res.writeHead(502, { "content-type": "application/json" });
156
+ } else {
157
+ ensureTerminalEvent(true, errorMsg);
103
158
  }
104
159
  if (!res.writableEnded) {
105
160
  res.end();
@@ -109,6 +164,9 @@ export function pipeUpstreamStream(
109
164
 
110
165
  nodeStream.on("end", () => {
111
166
  const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
167
+ if (!hasTerminalEvent && totalChunks > 0) {
168
+ ensureTerminalEvent(false);
169
+ }
112
170
  if (thinkingChunks > 0) {
113
171
  log(
114
172
  "info",
@@ -131,6 +189,9 @@ export function pipeUpstreamStream(
131
189
 
132
190
  nodeStream.on("close", () => {
133
191
  try {
192
+ if (!hasTerminalEvent && totalChunks > 0) {
193
+ ensureTerminalEvent(true, "stream closed prematurely");
194
+ }
134
195
  if (!res.writableEnded) res.end();
135
196
  } catch {}
136
197
  });