pi-openai-toolkit 0.15.1 → 0.17.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.
@@ -156,41 +156,53 @@ export function buildRemoteV2CompactionRequest(
156
156
  };
157
157
  }
158
158
 
159
- export function parseSseEvents(raw: string): ParsedSseEvent[] | undefined {
160
- const normalized = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
161
- const events: ParsedSseEvent[] = [];
162
-
163
- for (const block of normalized.split(/\n\n+/)) {
164
- if (!block.trim()) continue;
165
-
166
- let event: string | undefined;
167
- const dataLines: string[] = [];
168
- for (const line of block.split("\n")) {
169
- if (line.startsWith(":")) continue;
170
- if (line.startsWith("event:")) {
171
- event = line.slice("event:".length).trim();
159
+ /** Shared line framing; yielding lazily keeps bytes after a terminal out of parsing. */
160
+ class SseEventParser {
161
+ private line = "";
162
+ private skipLf = false;
163
+ private event: string | undefined;
164
+ private dataLines: string[] = [];
165
+
166
+ *push(text: string): Generator<ParsedSseEvent> {
167
+ for (const character of text) {
168
+ if (this.skipLf) {
169
+ this.skipLf = false;
170
+ if (character === "\n") continue;
171
+ }
172
+ if (character !== "\r" && character !== "\n") {
173
+ this.line += character;
172
174
  continue;
173
175
  }
174
- if (line.startsWith("data:")) {
175
- dataLines.push(line.slice("data:".length).replace(/^ /, ""));
176
+ this.skipLf = character === "\r";
177
+ const line = this.line;
178
+ this.line = "";
179
+ if (line === "") {
180
+ const event = this.event;
181
+ const dataLines = this.dataLines;
182
+ this.event = undefined;
183
+ this.dataLines = [];
184
+ if (dataLines.length === 0) continue;
185
+ const dataText = dataLines.join("\n");
186
+ yield dataText === "[DONE]"
187
+ ? { event, dataText }
188
+ : { event, dataText, data: JSON.parse(dataText) };
189
+ } else if (line.startsWith("event:")) {
190
+ this.event = line.slice("event:".length).trim();
191
+ } else if (line === "data" || line.startsWith("data:")) {
192
+ this.dataLines.push(line.slice("data:".length).replace(/^ /, ""));
176
193
  }
177
194
  }
178
-
179
- if (dataLines.length === 0) continue;
180
- const dataText = dataLines.join("\n");
181
- if (dataText === "[DONE]") {
182
- events.push({ event, dataText });
183
- continue;
184
- }
185
-
186
- try {
187
- events.push({ event, dataText, data: JSON.parse(dataText) });
188
- } catch {
189
- return undefined;
190
- }
191
195
  }
196
+ }
192
197
 
193
- return events.length > 0 ? events : undefined;
198
+ export function parseSseEvents(raw: string): ParsedSseEvent[] | undefined {
199
+ try {
200
+ // Retain the buffered helper's historical support for a final unterminated block.
201
+ const events = [...new SseEventParser().push(`${raw}\n\n`)];
202
+ return events.length > 0 ? events : undefined;
203
+ } catch {
204
+ return undefined;
205
+ }
194
206
  }
195
207
 
196
208
  function isRemoteV2CompactionItem(value: unknown): value is RemoteV2CompactionItem {
@@ -560,6 +572,68 @@ function reconcileSseCheckpoint(events: readonly ParsedSseEvent[]): CheckpointRe
560
572
  };
561
573
  }
562
574
 
575
+ type CollectedSse = {
576
+ responseText: string;
577
+ events?: ParsedSseEvent[];
578
+ reconciliation?: CheckpointReconciliation;
579
+ };
580
+
581
+ async function collectSseCheckpoint(response: Response, signal?: AbortSignal): Promise<CollectedSse> {
582
+ const reader = response.body?.getReader();
583
+ if (!reader) {
584
+ if (signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
585
+ return { responseText: "" };
586
+ }
587
+ const decoder = new TextDecoder();
588
+ const parser = new SseEventParser();
589
+ const events: ParsedSseEvent[] = [];
590
+ const text: string[] = [];
591
+ let onAbort: (() => void) | undefined;
592
+ const aborted = new Promise<never>((_resolve, reject) => {
593
+ onAbort = () => reject(new DOMException("Compaction aborted", "AbortError"));
594
+ signal?.addEventListener("abort", onAbort, { once: true });
595
+ });
596
+ try {
597
+ while (true) {
598
+ if (signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
599
+ const chunk = await Promise.race([reader.read(), aborted]);
600
+ if (signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
601
+ const decoded = chunk.done ? decoder.decode() : decoder.decode(chunk.value, { stream: true });
602
+ text.push(decoded);
603
+ try {
604
+ for (const event of parser.push(decoded)) {
605
+ events.push(event);
606
+ const type = getEventType(event);
607
+ if (type === "response.completed" || type === "error" || type === "response.failed" || type === "response.incomplete") {
608
+ // Validate synchronously before cleanup; later cancellation cannot undo this boundary.
609
+ return { responseText: "", events, reconciliation: reconcileSseCheckpoint(events) };
610
+ }
611
+ }
612
+ } catch (error) {
613
+ if (!(error instanceof SyntaxError)) throw error;
614
+ return { responseText: text.join("") };
615
+ }
616
+ // EOF never dispatches an unfinished frame, even if its JSON happens to be complete.
617
+ if (chunk.done) return { responseText: text.join(""), events: events.length ? events : undefined };
618
+ }
619
+ } finally {
620
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
621
+ // Cancellation is best-effort cleanup, not part of the completion boundary.
622
+ // Observe rejection immediately and release even if the transport never settles cancel().
623
+ try {
624
+ void reader.cancel().catch(() => undefined);
625
+ } catch {
626
+ // A custom reader may throw synchronously during cleanup.
627
+ } finally {
628
+ try {
629
+ reader.releaseLock();
630
+ } catch {
631
+ // Cleanup failures must not replace a validated checkpoint or cancellation.
632
+ }
633
+ }
634
+ }
635
+ }
636
+
563
637
  export async function executeRemoteV2Compaction(
564
638
  options: ExecuteRemoteV2CompactionOptions,
565
639
  ): Promise<RemoteV2CompactionClientResult> {
@@ -588,13 +662,13 @@ export async function executeRemoteV2Compaction(
588
662
  body: JSON.stringify(request),
589
663
  signal,
590
664
  });
591
- const responseText = await response.text();
592
665
  const responseHeaders: Record<string, string> = {};
593
666
  response.headers.forEach((value, key) => {
594
667
  responseHeaders[key] = value;
595
668
  });
596
669
 
597
670
  if (!response.ok) {
671
+ const responseText = await response.text();
598
672
  let responseJson: unknown;
599
673
  if (responseText.trim()) {
600
674
  try {
@@ -623,7 +697,9 @@ export async function executeRemoteV2Compaction(
623
697
  return failure;
624
698
  }
625
699
 
626
- if (!responseText.trim()) {
700
+ const collected = await collectSseCheckpoint(response, signal);
701
+ const { responseText, events } = collected;
702
+ if (!events && !responseText.trim()) {
627
703
  const failure: RemoteV2CompactionClientFailure = {
628
704
  ok: false,
629
705
  reason: "empty-body",
@@ -642,7 +718,6 @@ export async function executeRemoteV2Compaction(
642
718
  return failure;
643
719
  }
644
720
 
645
- const events = parseSseEvents(responseText);
646
721
  if (!events) {
647
722
  const failure: RemoteV2CompactionClientFailure = {
648
723
  ok: false,
@@ -663,7 +738,7 @@ export async function executeRemoteV2Compaction(
663
738
  return failure;
664
739
  }
665
740
 
666
- const reconciliation = reconcileSseCheckpoint(events);
741
+ const reconciliation = collected.reconciliation ?? reconcileSseCheckpoint(events);
667
742
  if (!reconciliation.ok) {
668
743
  const failure: RemoteV2CompactionClientFailure = {
669
744
  ok: false,