pi-freeflow 1.32.1 → 1.32.2

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.32.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+
3
9
  ## 1.32.1
4
10
 
5
11
  ### Patch Changes
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.32.2",
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",
@@ -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