pi-freeflow 1.32.1 → 1.33.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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.33.0
4
+
5
+ ### Minor Changes
6
+
7
+ - One new free model, picked the same way as the rest (`/model` → `freeflow` → pick):
8
+
9
+ - **Ling 3.1 Flash** on KiloCode (`inclusionai/ling-3.1-flash`, short name `ling-3.1-flash:kilo`) — 262K context, text answers.
10
+
11
+ ### Patch Changes
12
+
13
+ - Remove four free models that stopped serving upstream, so they no longer appear in the model list:
14
+
15
+ - **MiMo V2.5** on OpenCode (`mimo-v2.5-free`) — upstream answers `401 Model is not supported`.
16
+ - **Exo** on OpenCode (`exo-free`) — upstream answers `503 Endpoint is unavailable`.
17
+ - **Space Bunny Alpha** on KiloCode (`stealth/space-bunny-alpha`) — upstream answers `404 does not exist`.
18
+ - **Qwen 3.8 27B** on KiloCode (`qwen/qwen3.8-27b:free`) — upstream answers `404 currently unavailable`.
19
+
20
+ All four are excluded permanently, so a stale on-disk catalog or a later catalog refresh cannot bring them back.
21
+
22
+ - Requests that ask for a thinking level a model does not offer no longer fail: the level is clamped to the highest level that model supports (previously only Muse Spark was covered — now every model is), and a chat-style `response_format` left on a responses request is dropped instead of rejected upstream.
23
+
24
+ ## 1.32.2
25
+
26
+ ### Patch Changes
27
+
28
+ - Prevent JSON parse errors on interrupted responses: network packet boundaries split mid-delta no longer produce unterminated string syntax errors when a turn is retried or terminated.
29
+
3
30
  ## 1.32.1
4
31
 
5
32
  ### Patch Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-freeflow
2
2
 
3
- > 33 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
3
+ > 31 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
4
4
 
5
5
  Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handles thinking, normalization, and provider behavior.
6
6
 
@@ -16,7 +16,7 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
16
16
 
17
17
  | Feature | What it does | Cost |
18
18
  | :--- | :--- | :--- |
19
- | **33 free models** | 11 from OpenCode Zen, 17 from KiloCode Gateway, 5 from Cline, context windows up to 1M. Full list below. | **$0** |
19
+ | **31 free models** | 10 from OpenCode Zen, 16 from KiloCode Gateway, 5 from Cline, context windows up to 1M. Full list below. | **$0** |
20
20
  | **Relay pool** | Route requests through your own Cloudflare Workers and Vercel Edge relays. Traffic spreads across the pool, or stays on one sticky relay. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your platforms' free tiers |
21
21
  | **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
22
22
  | **Short model names** | Every model has a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`. | **$0** |
@@ -185,13 +185,13 @@ The same command set works identically in OMP and Pi:
185
185
 
186
186
  ---
187
187
 
188
- ### 33 models, one command
188
+ ### 31 models, one command
189
189
 
190
190
  ```bash
191
191
  /model → freeflow → pick
192
192
  ```
193
193
 
194
- #### OpenCode Zen (11 models), Responses and Chat API
194
+ #### OpenCode Zen (10 models), Responses and Chat API
195
195
 
196
196
  Good defaults for long coding sessions and agentic work.
197
197
 
@@ -199,7 +199,6 @@ Good defaults for long coding sessions and agentic work.
199
199
  | :--- | :--- | :--- | :--- | :--- | :--- |
200
200
  | `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
201
201
  | `muse-spark-1.3-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
202
- | `mimo-v2.5-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
203
202
  | `mimo-v2.6-flash-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
204
203
  | `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal … xhigh` | ❌ |
205
204
  | `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal … xhigh` | ❌ |
@@ -209,7 +208,7 @@ Good defaults for long coding sessions and agentic work.
209
208
  | `fledge-alpha-free` | Stealth preview (lab undisclosed) | **1M** (1.048.576) | **131K** (131.072) | `low / high / max` | ✅ |
210
209
  | `ling-3.1-flash-free` | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal … max` | ❌ |
211
210
 
212
- #### KiloCode Gateway (17 models), OpenRouter compatible
211
+ #### KiloCode Gateway (16 models), OpenRouter compatible
213
212
 
214
213
  Keyless access. Short aliases work for every row (the full ID is in parentheses).
215
214
 
@@ -229,9 +228,8 @@ Keyless access. Short aliases work for every row (the full ID is in parentheses)
229
228
  | `content-safety` (`nvidia/...:free`) | NVIDIA | **128K** (128.000) | **8K** (8.192) | ❌ *(non-thinking)* | ✅ |
230
229
  | `ling-3.0-flash-sante` (`inclusionai/ling-3.0-flash-sante:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
231
230
  | `step-3.7-flash` (`stepfun/...:free`) | StepFun | **262K** (262.144) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
232
- | `space-bunny-alpha` (`stealth/...`) | Stealth preview (lab undisclosed) | **1M** (1.000.000) | **512K** (524.288) | `minimal…xhigh`\* | ✅ |
233
231
  | `inkling-small` (`thinkingmachines/inkling-small:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
234
- | `qwen3.8-27b` (`qwen/...:free`) | Alibaba Qwen | **262K** (262.144) | **235K** (235.929) | `minimal…xhigh`\* | ✅ |
232
+ | `ling-3.1-flash-kilo` (`inclusionai/ling-3.1-flash`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
235
233
 
236
234
  \* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model. MiMo collapses `minimal→low` and `xhigh→high` upstream, so its selector shows 5 labels but only 3 distinct effort values.
237
235
 
@@ -344,7 +342,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
344
342
  ```
345
343
  src/
346
344
  ├── index.ts # extension entry, lifecycle hooks
347
- ├── models.ts # 33-model catalog definitions
345
+ ├── models.ts # 31-model catalog definitions
348
346
  ├── catalog.ts # model catalog cache (24h disk)
349
347
  ├── proxy.ts # local proxy server (127.0.0.1:28180)
350
348
  ├── relay.ts # relay fetch, rolling on retryable failures
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.32.1",
4
+ "version": "1.33.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",
package/src/catalog.ts CHANGED
@@ -42,6 +42,10 @@ import type {
42
42
  * reproduced across separate runs on different days while every other Zen model answers.
43
43
  * - inclusionai/ling-3.0-flash-fin:free: gone from Kilo (2026-10-01) — keyless chat 404
44
44
  * "The requested model ... does not exist".
45
+ * - mimo-v2.5-free: decommissioned upstream (2026-10-07) — HTTP 401 "Model mimo-v2.5-free is not supported".
46
+ * - exo-free: unserved upstream (2026-10-07) — HTTP 503 "Error from provider (Console): Upstream request failed: Endpoint is unavailable.".
47
+ * - stealth/space-bunny-alpha: gone from Kilo (2026-10-07) — keyless chat 404 "The requested model ... does not exist".
48
+ * - qwen/qwen3.8-27b:free: gone from Kilo (2026-10-07) — keyless chat 404 "The requested model is currently unavailable.".
45
49
  */
46
50
  export const DEAD_MODEL_IDS = new Set<string>([
47
51
  "jev-1.13-free",
@@ -61,13 +65,17 @@ export const DEAD_MODEL_IDS = new Set<string>([
61
65
  "inclusionai/ling-3.0-flash-vl:free",
62
66
  "ling-3.0-flash-fin-free",
63
67
  "inclusionai/ling-3.0-flash-fin:free",
68
+ "mimo-v2.5-free",
69
+ "exo-free",
70
+ "stealth/space-bunny-alpha",
71
+ "qwen/qwen3.8-27b:free",
64
72
  ]);
65
73
  /**
66
74
  * Free-tier allowlist for anything entering the picker via network or stale disk.
67
75
  * Upstream lists paid models alongside free ones (e.g. claude-fable-5-1,
68
76
  * claude-opus-4-*, gemini-3-*) so a bare upstream merge leaks paid entries that
69
77
  * fail with 401 Missing API key. Known static IDs without a free suffix
70
- * (e.g. big-pickle, stealth/space-bunny-alpha) stay allowed via MODEL_MAP.
78
+ * (e.g. big-pickle, inclusionai/ling-3.1-flash) stay allowed via MODEL_MAP.
71
79
  */
72
80
  export function isFreeCatalogId(id: string): boolean {
73
81
  if (typeof id !== "string" || id.length === 0) return false;
@@ -100,7 +108,7 @@ export function sanitizeCatalogModels(models: RegisteredModel[]): RegisteredMode
100
108
  }
101
109
  /**
102
110
  * In-memory cache of currently active/available free models.
103
- * Initialized with all 33 verified models for 0ms instant availability.
111
+ * Initialized with all 31 verified models for 0ms instant availability.
104
112
  */
105
113
  let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
106
114
  ...m,
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
3
3
  *
4
- * Provides access to 33 free models (11 OpenCode Zen + 17 KiloCode Gateway + 5 Cline) with:
4
+ * Provides access to 31 free models (10 OpenCode Zen + 16 KiloCode Gateway + 5 Cline) with:
5
5
  * - Single-port daemon reuse on 28180 across concurrent subagents
6
6
  * - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
7
7
  * - 0ms instant startup with verified static catalog and background live health checks
package/src/models.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
- * Defines the 33 verified free models (live-verified 2026-09-28):
4
+ * Defines the 31 verified free models (live-verified 2026-10-07):
5
5
  * - 10 OpenCode Zen models (2 Responses API + 8 Chat Completions)
6
- * - 18 KiloCode Keyless Gateway models (OpenRouter format)
6
+ * - 16 KiloCode Keyless Gateway models (OpenRouter format)
7
7
  * - 5 Cline direct-only models (per-user pool)
8
8
  */
9
9
 
@@ -50,23 +50,6 @@ export const OPENCODE_MODELS: ModelDef[] = [
50
50
  max: null,
51
51
  },
52
52
  },
53
- {
54
- id: "mimo-v2.5-free",
55
- name: "MiMo V2.5 [OpenCode]",
56
- reasoning: true,
57
- contextWindow: 1_048_576,
58
- maxTokens: 131_072,
59
- input: ["text", "image"],
60
- thinkingLevelMap: {
61
- off: null,
62
- minimal: "low",
63
- low: "low",
64
- medium: "medium",
65
- high: "high",
66
- xhigh: "high",
67
- max: null,
68
- },
69
- },
70
53
  {
71
54
  id: "mimo-v2.6-flash-free",
72
55
  name: "MiMo V2.6 Flash [OpenCode]",
@@ -356,19 +339,6 @@ export const KILO_MODELS: ModelDef[] = [
356
339
  thinkingFormat: "openrouter",
357
340
  thinkingLevelMap: KILO_REASONING_MAP,
358
341
  },
359
- {
360
- // Added 2026-09-28: live on the Kilo free list (isFree:true, 0/0
361
- // pricing, 1M context, text+image in) + keyless chat 200. Passes
362
- // isFreeCatalogId via MODEL_MAP (no :free suffix by design).
363
- id: "stealth/space-bunny-alpha",
364
- name: "Space Bunny Alpha [Kilo]",
365
- reasoning: true,
366
- contextWindow: 1_000_000,
367
- maxTokens: 524_288,
368
- input: ["text", "image"],
369
- thinkingFormat: "openrouter",
370
- thinkingLevelMap: KILO_REASONING_MAP,
371
- },
372
342
  {
373
343
  id: "thinkingmachines/inkling-small:free",
374
344
  name: "Inkling Small [Kilo]",
@@ -390,12 +360,13 @@ export const KILO_MODELS: ModelDef[] = [
390
360
  thinkingLevelMap: KILO_REASONING_MAP,
391
361
  },
392
362
  {
393
- id: "qwen/qwen3.8-27b:free",
394
- name: "Qwen 3.8 27B [Kilo]",
363
+ // Added 2026-10-07: live on Kilo free list (isFree:true, 0/0 pricing, 262K ctx)
364
+ id: "inclusionai/ling-3.1-flash",
365
+ name: "Ling 3.1 Flash [Kilo]",
395
366
  reasoning: true,
396
367
  contextWindow: 262_144,
397
- maxTokens: 235_929,
398
- input: ["text", "image"],
368
+ maxTokens: 32_768,
369
+ input: ["text"],
399
370
  thinkingFormat: "openrouter",
400
371
  thinkingLevelMap: KILO_REASONING_MAP,
401
372
  },
@@ -517,7 +488,6 @@ export const MODEL_ALIASES: Record<string, string> = {
517
488
  "content-safety": "nvidia/nemotron-3.5-content-safety:free",
518
489
  "ling-3.0-flash-sante": "inclusionai/ling-3.0-flash-sante:free",
519
490
  "step-3.7-flash": "stepfun/step-3.7-flash:free",
520
- "space-bunny-alpha": "stealth/space-bunny-alpha",
521
491
  "inkling-small": "thinkingmachines/inkling-small:free",
522
492
  "mimo-v2.6-flash": "mimo-v2.6-flash-free",
523
493
  "space-bunny": "space-bunny-free",
@@ -525,7 +495,7 @@ export const MODEL_ALIASES: Record<string, string> = {
525
495
  "longcat": "longcat-2.5-preview-free",
526
496
  "fledge-alpha": "fledge-alpha-free",
527
497
  "ling-3.1-flash": "ling-3.1-flash-free",
528
- "qwen3.8-27b": "qwen/qwen3.8-27b:free",
498
+ "ling-3.1-flash:kilo": "inclusionai/ling-3.1-flash",
529
499
  // provider-prefixed short aliases (slash-normalized)
530
500
  "laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
531
501
  "laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
@@ -559,9 +529,8 @@ export const CLINE_MODEL_IDS = new Set<string>([
559
529
  .filter(([_, target]) => CLINE_MODELS.some((cm) => cm.id === target))
560
530
  .map(([alias]) => alias),
561
531
  ]);
562
-
563
532
  /**
564
- * Combined list of all 33 static free models (canonical)
533
+ * Combined list of all 31 static free models (canonical)
565
534
  */
566
535
  export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS, ...CLINE_MODELS];
567
536
 
@@ -38,6 +38,35 @@ import {
38
38
  type FindGlobRestore,
39
39
  type FingerprintToolName,
40
40
  } from "./tool-translation.ts";
41
+ import { getModelDef } from "./models.ts";
42
+ import type { ThinkingLevel } from "./types.ts";
43
+
44
+ /** Effort levels in descending priority (off excluded — clamping to off is never correct). */
45
+ const EFFORT_DESCENDING: ThinkingLevel[] = ["max", "xhigh", "high", "medium", "low", "minimal"];
46
+
47
+ /**
48
+ * Find the highest supported effort for a model whose thinkingLevelMap
49
+ * declares the requested effort as null. Returns null when no clamp is
50
+ * needed (effort is supported or model has no map).
51
+ */
52
+ function clampedEffortFor(model: string, effort: string): string | null {
53
+ const def = getModelDef(model);
54
+ if (!def?.thinkingLevelMap) return null;
55
+ const map = def.thinkingLevelMap;
56
+ // "off" means disable reasoning entirely — never clamp it even when map says null
57
+ if (effort.toLowerCase() === "off") return null;
58
+ // "ultra" is an unofficial alias for beyond-max; treat as "max"
59
+ const key = (effort.toLowerCase() === "ultra" ? "max" : effort.toLowerCase()) as ThinkingLevel;
60
+ // Effort is supported (non-null) → no clamp
61
+ if (key in map && map[key] !== null && map[key] !== undefined) return null;
62
+ // Effort key is unknown and not "ultra" → let upstream decide
63
+ if (!(key in map) && effort.toLowerCase() !== "ultra") return null;
64
+ // Find highest supported effort
65
+ for (const level of EFFORT_DESCENDING) {
66
+ if (map[level] !== null && map[level] !== undefined) return level;
67
+ }
68
+ return null;
69
+ }
41
70
 
42
71
  /**
43
72
  * Safely extract the tool name whether formatted in Chat Completions style
@@ -262,14 +291,15 @@ export function ensureMessagesFingerprintTools(body: Record<string, unknown>, om
262
291
  * - `max_tokens` / `max_completion_tokens` become `max_output_tokens` (copied
263
292
  * when absent, dropped either way).
264
293
  * - `temperature` / `top_p` are dropped (Responses rejects them).
294
+ * - `response_format` is dropped (Chat Completions field, Responses rejects it).
265
295
  * - `parallel_tool_calls: false` is dropped; absent-or-true rides verbatim.
266
296
  * - `tool_choice` is auto-or-absent: `"none"` (string or `{ type: "none" }`)
267
297
  * is dropped, everything else rides (named choices still retarget find->glob).
268
298
  * - `store` defaults to false (never persisted server-side).
269
- * - spark reasoning effort clamps `max` / `ultra` to `xhigh`: the spark
270
- * thinkingLevelMap declares `max: null` (unoffered) and upstream serves at
271
- * most `xhigh`, so a stale or hand-built `max`/`ultra` effort would 400.
272
- * Only spark model ids are clamped; `off: null` stays untouched everywhere.
299
+ * - Reasoning effort is clamped per model: when the model's thinkingLevelMap
300
+ * declares the requested effort as null (unsupported), the effort is clamped
301
+ * to the highest supported level. Covers all OpenCode models generically
302
+ * (muse-spark, mimo, nemotron, etc.), not just a single model family.
273
303
  * Zen-responses only: chat/messages paths keep their own fields, and Kilo/Cline
274
304
  * bodies never reach here (the proxy fingerprints Zen bodies exclusively).
275
305
  */
@@ -287,23 +317,28 @@ export function normalizeResponsesBody(body: Record<string, unknown>): void {
287
317
  delete body.max_completion_tokens;
288
318
  delete body.temperature;
289
319
  delete body.top_p;
320
+ delete body.response_format;
290
321
  if (body.parallel_tool_calls === false) delete body.parallel_tool_calls;
291
322
  const choice = body.tool_choice;
292
323
  if (typeof choice === "string" ? choice.toLowerCase() === "none" : (
293
324
  choice !== null && typeof choice === "object" && !Array.isArray(choice)
294
325
  && (choice as Record<string, unknown>).type === "none"
295
326
  )) delete body.tool_choice;
296
- if (typeof body.model === "string" && /muse-spark/i.test(body.model)) {
327
+ if (typeof body.model === "string") {
297
328
  const reasoning = body.reasoning;
298
329
  if (reasoning !== null && typeof reasoning === "object" && !Array.isArray(reasoning)) {
299
330
  const effort = (reasoning as Record<string, unknown>).effort;
300
- if (typeof effort === "string" && (effort.toLowerCase() === "max" || effort.toLowerCase() === "ultra")) {
301
- (reasoning as Record<string, unknown>).effort = "xhigh";
331
+ if (typeof effort === "string") {
332
+ const clamped = clampedEffortFor(body.model as string, effort);
333
+ if (clamped !== null) (reasoning as Record<string, unknown>).effort = clamped;
302
334
  }
303
335
  }
304
336
  for (const key of ["reasoning_effort", "reasoningEffort"] as const) {
305
337
  const flat = body[key];
306
- if (typeof flat === "string" && (flat.toLowerCase() === "max" || flat.toLowerCase() === "ultra")) body[key] = "xhigh";
338
+ if (typeof flat === "string") {
339
+ const clamped = clampedEffortFor(body.model as string, flat);
340
+ if (clamped !== null) body[key] = clamped;
341
+ }
307
342
  }
308
343
  }
309
344
  if (body.store === undefined) body.store = false;
@@ -745,6 +745,8 @@ export function pipeUpstreamStream(
745
745
  const cut = nodeStream;
746
746
  upstreamEnded = false;
747
747
  nodeStream = next;
748
+ sseBuffer = "";
749
+ terminalScanCarry = Buffer.alloc(0);
748
750
  watch(next);
749
751
  try {
750
752
  if (!cut.destroyed) cut.destroy();
@@ -805,11 +807,11 @@ export function pipeUpstreamStream(
805
807
  try {
806
808
  if (isError) {
807
809
  res.write(
808
- `\nevent: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"stream_error","message":${JSON.stringify(errorMsg || "Upstream stream disconnected unexpectedly")}}}}\n\n`,
810
+ `\n\nevent: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"stream_error","message":${JSON.stringify(errorMsg || "Upstream stream disconnected unexpectedly")}}}}\n\n`,
809
811
  );
810
812
  } else {
811
813
  res.write(
812
- `\nevent: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"cancelled"}}}\n\n`,
814
+ `\n\nevent: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"cancelled"}}}\n\n`,
813
815
  );
814
816
  }
815
817
  hasTerminalEvent = true;
@@ -822,7 +824,7 @@ export function pipeUpstreamStream(
822
824
  } catch { }
823
825
  } else if (!isResponsesApi && totalChunks > 0) {
824
826
  try {
825
- res.write("\ndata: [DONE]\n\n");
827
+ res.write("\n\ndata: [DONE]\n\n");
826
828
  hasTerminalEvent = true;
827
829
  } catch { }
828
830
  }
@@ -860,6 +862,8 @@ export function pipeUpstreamStream(
860
862
  }
861
863
  upstreamEnded = false;
862
864
  nodeStream = next;
865
+ sseBuffer = "";
866
+ terminalScanCarry = Buffer.alloc(0);
863
867
  watch(next);
864
868
  log("debug", "mid-stream refire: continuing turn on replacement stream", { totalChunks, totalBytes }, rid);
865
869
  return true;
@@ -902,59 +906,22 @@ export function pipeUpstreamStream(
902
906
  );
903
907
  }
904
908
  }
905
- if (cloakState && cloak) {
906
- // Cloaked stream: buffer across chunks so event names split mid-chunk
907
- // still match, rewrite complete SSE blocks, and drop injected calls.
908
- sseBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
909
- let boundary = sseBoundary.exec(sseBuffer);
910
- while (boundary) {
911
- const block = sseBuffer.slice(0, boundary.index);
912
- sseBuffer = sseBuffer.slice(boundary.index + boundary[0].length);
909
+ // Buffer across chunks into complete SSE blocks delimited by double-newline.
910
+ // Forwarding only complete blocks guarantees that network chunk boundaries
911
+ // never split JSON strings on the wire or leave unterminated syntax when
912
+ // mid-stream refire or truncation occurs.
913
+ sseBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
914
+ let boundary = sseBoundary.exec(sseBuffer);
915
+ while (boundary) {
916
+ const block = sseBuffer.slice(0, boundary.index);
917
+ sseBuffer = sseBuffer.slice(boundary.index + boundary[0].length);
918
+ if (cloakState && cloak) {
913
919
  const rewritten = rewriteSseBlock(block, cloakState, cloak);
914
920
  if (rewritten !== null) forwardText(`${rewritten}\n\n`);
915
- boundary = sseBoundary.exec(sseBuffer);
921
+ } else {
922
+ forwardText(`${block}\n\n`);
916
923
  }
917
- return;
918
- }
919
- // Always scan the FULL chunk plus the small carry from the previous
920
- // view. SSE chunks are KBs at most, so includes() over everything
921
- // is negligible against correctness — the removed head/tail windows
922
- // are exactly what let markers buried mid-chunk or split across
923
- // adjacent chunks escape and cause duplicate synthetic terminals.
924
- const buf =
925
- typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
926
- const scanBuf =
927
- terminalScanCarry.length > 0
928
- ? Buffer.concat([terminalScanCarry, buf])
929
- : buf;
930
- const scanned = scanBuf.toString("utf8");
931
- if (!sawToolCall && TOOL_CALL_MARKERS.some((m) => scanned.includes(m))) sawToolCall = true;
932
- if (!hasTerminalEvent && checkTerminalEvent(scanned)) {
933
- // Upstream-issued transient model failure on a text-only turn: hold the
934
- // frame for one silent resume instead of terminating (tool turns veto
935
- // inside holdModelFailureForRefire and keep the terminal below).
936
- if (isTransientModelFailureFrame(scanned) && holdModelFailureForRefire(chunk)) return;
937
- hasTerminalEvent = true;
938
- }
939
- terminalScanCarry = Buffer.from(scanBuf.subarray(Math.max(0, scanBuf.length - 32)));
940
-
941
- const canContinue = res.write(chunk);
942
- if (canContinue === false && !nodeStream.destroyed) {
943
- // Backpressure: pause the upstream source until the client
944
- // socket drains, instead of buffering an unbounded amount into
945
- // the response. 'drain' might never fire if the client closes —
946
- // the close handlers below destroy the stream regardless of
947
- // its paused state.
948
- nodeStream.pause();
949
- const onDrain = () => {
950
- res.off("drain", onDrain);
951
- if (!nodeStream.destroyed) nodeStream.resume();
952
- };
953
- res.once("drain", onDrain);
954
- }
955
- const maybeFlush = res as unknown as { flush?: () => void };
956
- if (typeof maybeFlush.flush === "function") {
957
- maybeFlush.flush();
924
+ boundary = sseBoundary.exec(sseBuffer);
958
925
  }
959
926
  } catch { }
960
927
  };
@@ -985,9 +952,13 @@ export function pipeUpstreamStream(
985
952
  res.writeHead(502, { "content-type": "application/json" });
986
953
  } else if (isInternalAbort) {
987
954
  clientAborted = true;
955
+ sseBuffer = "";
956
+ terminalScanCarry = Buffer.alloc(0);
988
957
  ensureTerminalEvent(false, errorMsg || "stream interrupted", false);
989
958
  } else {
990
959
  if (await tryRefire()) return;
960
+ sseBuffer = "";
961
+ terminalScanCarry = Buffer.alloc(0);
991
962
  ensureTerminalEvent(!isSubstantial(totalChunks, totalBytes), errorMsg, true);
992
963
  }
993
964
  if (!res.writableEnded) {
@@ -1003,11 +974,17 @@ export function pipeUpstreamStream(
1003
974
  upstreamEnded = true;
1004
975
  // Cloaked streams hold the trailing incomplete block: flush it (rewritten)
1005
976
  // ahead of the terminal-marker check so a coalesced final event still counts.
1006
- if (cloakState && cloak && sseBuffer.trim() !== "") {
977
+ if (sseBuffer.trim() !== "") {
1007
978
  const tail = sseBuffer;
1008
979
  sseBuffer = "";
1009
- const rewritten = rewriteSseBlock(tail, cloakState, cloak);
1010
- if (rewritten !== null) forwardText(`${rewritten}\n\n`);
980
+ if (checkTerminalEvent(tail)) {
981
+ if (cloakState && cloak) {
982
+ const rewritten = rewriteSseBlock(tail, cloakState, cloak);
983
+ if (rewritten !== null) forwardText(`${rewritten}\n\n`);
984
+ } else {
985
+ forwardText(tail);
986
+ }
987
+ }
1011
988
  }
1012
989
  if (hasTerminalEvent) recordOnce(false);
1013
990
  const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
@@ -1054,11 +1031,15 @@ export function pipeUpstreamStream(
1054
1031
  if (clientAborted || upstreamEnded) {
1055
1032
  // Client disconnect teardown or post-end cleanup — the relay
1056
1033
  // is not at fault; still give the host a terminal event.
1034
+ sseBuffer = "";
1035
+ terminalScanCarry = Buffer.alloc(0);
1057
1036
  ensureTerminalEvent(false, "stream interrupted by client", false);
1058
1037
  } else {
1059
1038
  // Same offer as the clean-end path above: one silent re-fire before
1060
1039
  // marking this relay and terminating the turn.
1061
1040
  if (await tryRefire()) return;
1041
+ sseBuffer = "";
1042
+ terminalScanCarry = Buffer.alloc(0);
1062
1043
  // Upstream socket died mid-stream with no error event.
1063
1044
  // For muse-spark large payloads: raxtant 514KB failed but feoni 802KB
1064
1045
  // succeeded with same 2.6MB in — so this is edge-specific, not pure