-
Notifications
You must be signed in to change notification settings - Fork 46
Expand file tree
/
Copy pathstream-collapse.ts
More file actions
1839 lines (1709 loc) · 75.3 KB
/
Copy pathstream-collapse.ts
File metadata and controls
1839 lines (1709 loc) · 75.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Stream collapsing functions for record-and-replay.
*
* Each function takes a raw streaming response body (SSE, NDJSON, or binary
* EventStream) and collapses it into a non-streaming fixture response
* containing `{ content }`, `{ toolCalls }`, or both when the stream includes
* text followed by tool calls.
*/
import { crc32 } from "node:zlib";
import type { FixtureBlock, RecordProviderKey, ToolCall } from "./types.js";
import type { Logger } from "./logger.js";
import { isHarmonyContent, parseHarmonyContent } from "./harmony.js";
// ---------------------------------------------------------------------------
// Accumulated-string cap (RangeError: Invalid string length guard)
// ---------------------------------------------------------------------------
/**
* Hard ceiling (UTF-16 code units) for any SINGLE string a collapser
* accumulates across deltas (`content`, `reasoning`, a tool call's
* `arguments`, `audioB64`, `argsStr`, and the coalesced text of `orderAtoms`).
*
* V8's maximum string length on 64-bit is 2^29 - 1 (~536.8M UTF-16 code
* units); appending past it throws `RangeError: Invalid string length`. The
* proxy path bounds the raw BYTE buffer under `PROXY_BUFFER_HARD_CEILING`
* (256 MiB) before collapse, but the collapser is ALSO reachable directly
* (exported from index.ts) and — even on the proxy path — a single accumulator
* can approach the byte budget in code units, so it needs its own guard rather
* than relying on the byte cap alone. 256Mi code units is comfortably below
* V8's limit while leaving huge headroom for any real completion (whose
* accumulated content can never exceed the total streamed body).
*
* When an append would cross the ceiling the accumulator keeps the ceiling's
* worth of characters, drops the overflow, and marks itself truncated — never
* throwing — mirroring the proxy buffer's "bound then mark truncated, never
* fail the client" discipline.
*/
export const MAX_COLLAPSE_STRING_LENGTH = 256 * 1024 * 1024; // 256 Mi UTF-16 code units
/**
* Test-only override of {@link MAX_COLLAPSE_STRING_LENGTH}. Lets the cap suite
* exercise the truncation path with a tiny budget instead of building a
* multi-hundred-MB string. `undefined` (the default) uses the real ceiling.
* NEVER set from production code.
*/
let collapseStringLimitOverride: number | undefined;
/** @internal test-only — see {@link collapseStringLimitOverride}. */
export function setCollapseStringLimitForTests(value: number | undefined): void {
collapseStringLimitOverride = value;
}
/** Effective accumulated-string ceiling, honoring any active test-only override. */
function effectiveCollapseStringLimit(): number {
return collapseStringLimitOverride ?? MAX_COLLAPSE_STRING_LENGTH;
}
/**
* Guard a raw collapse-input body against V8's max string length BEFORE any
* per-delta accumulation begins. Every collapser accumulates `content` /
* `reasoning` / a tool call's `arguments` / `audioB64` / `argsStr` (and the
* coalesced text of `orderAtoms`) from fragments of `body`; the sum of any one
* of those accumulators can never exceed the total input length, so bounding
* the INPUT under the ceiling bounds EVERY accumulator at once — no per-`+=`
* site can then build a string past the limit and throw
* `RangeError: Invalid string length` (the ~1/sec prod crash).
*
* Returns the body UNCHANGED when it is within the ceiling. When it exceeds the
* ceiling this returns a hard-truncated prefix (never throwing) — the collapse
* result the caller builds from it is marked incomplete via `truncated: true`
* (see {@link isCollapseInputTruncated}). This mirrors the proxy buffer's
* "bound then mark truncated, never fail the client" discipline: on the proxy
* path a body this large has already tripped the byte cap and skipped recording,
* so this is a defense-in-depth backstop that also covers the direct (exported)
* collapse callers where no byte cap ran.
*/
function guardCollapseBody(body: string): string {
const limit = effectiveCollapseStringLimit();
if (body.length <= limit) return body;
return body.slice(0, limit);
}
/**
* True when a body exceeded the accumulated-string ceiling and had to be
* truncated by {@link guardCollapseBody}. Collapsers stamp `truncated: true` on
* their result in this case so the recorder skips journaling a partial fixture,
* exactly like a transport-truncated stream.
*/
function isCollapseInputTruncated(body: string): boolean {
return body.length > effectiveCollapseStringLimit();
}
// ---------------------------------------------------------------------------
// Result type shared by all collapse functions
// ---------------------------------------------------------------------------
export interface CollapseResult {
content?: string;
reasoning?: string;
/**
* The real cryptographic `signature` value captured from an Anthropic
* `signature_delta`. Carried so a recorded real-provider thinking turn can
* replay its ACTUAL signature instead of aimock's placeholder. Absent when the
* stream carried no signature. Single-signature assumption: a turn with
* MULTIPLE thinking blocks collapses to one merged `reasoning` string carrying
* only the FINAL block's signature (last-signature-wins) — per-block fidelity
* is not preserved. The recorder persists this only alongside a non-empty
* `reasoning` (a bare signature has nothing to attach to on replay); see
* `TextResponse.reasoningSignature` in types.ts.
*/
reasoningSignature?: string;
/**
* The opaque `data` payload(s) of any Anthropic `redacted_thinking` blocks, in
* stream order. Captured so a recorded redacted-thinking turn round-trips its
* encrypted reasoning faithfully. Absent when none present.
*/
redactedThinking?: string[];
webSearches?: string[];
toolCalls?: ToolCall[];
droppedChunks?: number;
firstDroppedSample?: string;
truncated?: boolean;
audioB64?: string;
audioMimeType?: string;
/**
* Set when harmony channel tokens were present in the accumulated content but
* could NOT be parsed into a complete, valid harmony structure. The content
* is preserved VERBATIM, so this is NOT transport loss — it is distinct from
* `droppedChunks` / `truncated`, which are reserved for genuine transport loss
* (malformed SSE/NDJSON frames, CRC mismatch). The caller surfaces this as a
* dedicated warning rather than a dropped/truncated-chunk warning.
*/
harmonyUnparsed?: true;
/** Short human-readable note accompanying {@link harmonyUnparsed}. */
harmonyNote?: string;
/**
* Ordered cross-channel block list, in STREAM order, populated ONLY when the
* stream is "interleaved" — i.e. a tool-call delta appeared STRICTLY BEFORE
* the first content delta, OR a content delta appeared AFTER any tool-call
* delta. The flat `content` / `toolCalls` fields stay populated UNCHANGED for
* replay back-compat and non-block consumers; `blocks` is purely additive
* positional instrumentation the recorder consults to decide whether to
* persist the ordered shape. Absent (undefined) for text-first, text-only,
* and tool-only streams — i.e. anything NOT interleaved — so the recorder
* keeps the legacy `{ content, toolCalls }` shape byte-identical.
*
* Each text block coalesces all contiguous content deltas between tool
* atoms; each toolCall block carries the fully-assembled name/arguments/id
* for one tool call in the position its FIRST delta arrived.
*/
blocks?: FixtureBlock[];
}
// ---------------------------------------------------------------------------
// Cross-channel block-order instrumentation (#274)
// ---------------------------------------------------------------------------
/**
* Atom recorded during a collapse pass, in stream arrival order. A `text` atom
* carries one content delta's text (contiguous text atoms are coalesced when
* building blocks); a `toolCall` atom is a stable reference to a tool-call
* accumulator whose name/arguments/id are filled in across later deltas. The
* `ref` is the SAME object stored in the collapser's `toolCallMap` (or pushed
* to a flat `toolCalls` array), so block identity is reconciled with the flat
* representation at finalize time — see {@link buildOrderedBlocks}.
*/
type OrderAtom =
| { kind: "text"; text: string }
| { kind: "toolCall"; ref: { name: string; arguments: string; id?: string } };
/**
* Normalize a tool call's accumulated `arguments` into valid JSON exactly like
* the flat-`toolCalls` recorder path: an empty / whitespace-only / missing
* value becomes `"{}"`, never `""`. Mirrors `recorder.ts` `toToolCallArguments`
* so a `blocks[].arguments` value is always parseable JSON and never disagrees
* with the flat `toolCalls` entry for the same call.
*/
function normalizeToolArguments(args: string | undefined): string {
if (args === undefined || args.trim() === "") return "{}";
return args;
}
/**
* Build a finalized {@link FixtureBlock.toolCall} from a tool-call accumulator,
* normalizing `arguments` so the block agrees byte-for-byte with the flat
* `toolCalls` entry built from the SAME accumulator object.
*/
function toToolCallBlock(ref: { name: string; arguments: string; id?: string }): FixtureBlock {
return {
type: "toolCall",
name: ref.name,
arguments: normalizeToolArguments(ref.arguments),
...(ref.id ? { id: ref.id } : {}),
};
}
/**
* Decide whether a recorded atom sequence is "interleaved" and, if so, build
* the ordered {@link FixtureBlock} list. Returns `undefined` when NOT
* interleaved (text-first, text-only, or tool-only) so callers leave
* `CollapseResult.blocks` unset and the recorder keeps the legacy shape.
*
* Interleaved ⇔ (a tool atom appears strictly before the first text atom) OR
* (a text atom appears after any tool atom). A stream with no tool atoms, or
* with no text atoms, is never interleaved. Text-first-then-tools is the common
* legacy case and is explicitly NOT interleaved.
*
* CONSISTENCY (#274): each toolCall block is derived from the SAME accumulator
* object referenced by its atom and normalized identically to the flat
* `toolCalls` path ({@link toToolCallBlock} / {@link normalizeToolArguments}).
* Because the atom `ref` is the very object the flat list is built from, the
* block and its flat counterpart describe the same call by identity — even when
* upstream tool-call indices do not match stream-arrival order. Empty/missing
* arguments normalize to `"{}"` in BOTH representations, never `""`.
*/
function buildOrderedBlocks(atoms: OrderAtom[]): FixtureBlock[] | undefined {
let firstTextIndex = -1;
let firstToolIndex = -1;
let textAfterTool = false;
let sawTool = false;
let sawText = false;
for (let i = 0; i < atoms.length; i++) {
const a = atoms[i];
if (a.kind === "text") {
sawText = true;
if (firstTextIndex === -1) firstTextIndex = i;
if (sawTool) textAfterTool = true;
} else {
sawTool = true;
if (firstToolIndex === -1) firstToolIndex = i;
}
}
// No cross-channel ordering to express unless BOTH channels appear.
if (!sawTool || !sawText) return undefined;
const toolBeforeText = firstToolIndex < firstTextIndex;
if (!toolBeforeText && !textAfterTool) return undefined;
// Coalesce contiguous text atoms into one text block; emit each tool atom as
// a toolCall block reflecting its fully-assembled, normalized accumulator.
const blocks: FixtureBlock[] = [];
let pendingText = "";
let hasPendingText = false;
const flushText = () => {
if (hasPendingText) {
blocks.push({ type: "text", text: pendingText });
pendingText = "";
hasPendingText = false;
}
};
for (const a of atoms) {
if (a.kind === "text") {
pendingText += a.text;
hasPendingText = true;
} else {
flushText();
blocks.push(toToolCallBlock(a.ref));
}
}
flushText();
return blocks;
}
/**
* The opaque `data` of a non-empty Anthropic `redacted_thinking` block, or
* `undefined` if `block` is not a redacted_thinking block or carries empty/no
* data. NON-EMPTY is required: the replay-side validator rejects a leading
* empty-data redacted_thinking block, so recording `data: ""` would yield a
* fixture that 400s under strict replay. Shared by every capture site (SSE,
* Anthropic-native binary, non-streaming recorder) so the rule stays in one
* place.
*/
export function capturedRedactedData(
block: Record<string, unknown> | undefined,
): string | undefined {
if (
block?.type === "redacted_thinking" &&
typeof block.data === "string" &&
block.data.length > 0
) {
return block.data;
}
return undefined;
}
/**
* Slice the first `max` UTF-16 code units of `s` for a diagnostic sample,
* trimming a trailing lone high-surrogate so the resulting sample never ends on
* a lone high surrogate (i.e. never mid-surrogate-pair).
*/
function surrogateSafeSlice(s: string, max: number): string {
let out = s.slice(0, max);
if (out.length > 0) {
const last = out.charCodeAt(out.length - 1);
// A high surrogate (U+D800..U+DBFF) at the end is the lead of a split pair.
if (last >= 0xd800 && last <= 0xdbff) {
out = out.slice(0, -1);
}
}
return out;
}
/**
* Split a raw SSE body into per-event blocks.
*
* Events are delimited by a blank line. Real HTTP/SSE transports use CRLF
* (`\r\n`) line endings, so the inter-event delimiter is `\r\n\r\n` (which
* contains no `\n\n` substring) and each line ends with a trailing `\r`.
* Splitting on `/\r?\n\r?\n/` handles LF, CRLF, and mixed streams; per-line
* `\r` trimming happens in {@link splitSSELines}. Blank blocks are dropped.
*/
function splitSSEEvents(body: string): string[] {
return body.split(/\r?\n\r?\n/).filter((block) => block.trim().length > 0);
}
/**
* Split a single SSE event block into its lines, trimming a trailing `\r` so
* CRLF streams parse identically to LF streams.
*/
function splitSSELines(block: string): string[] {
return block.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
}
/**
* Extract the SSE `data` field from a single event block's lines.
*
* Per the SSE spec a single event may carry MULTIPLE `data:` lines; the field
* value is every data line's content joined with "\n". Collecting only the
* first `data:` line (e.g. via `.find`) corrupts payloads that a server split
* across lines. Callers MUST pass lines produced by {@link splitSSELines} so
* any trailing `\r` is already stripped. Returns the joined payload (with the
* leading "data:" prefix and one optional leading space stripped per line), or
* `undefined` when the block contains no `data:` line.
*/
function extractSSEData(lines: string[]): string | undefined {
const dataParts: string[] = [];
for (const line of lines) {
if (!line.startsWith("data:")) continue;
// Strip "data:" then a single optional leading space, per the SSE spec.
let part = line.slice(5);
if (part.startsWith(" ")) part = part.slice(1);
dataParts.push(part);
}
if (dataParts.length === 0) return undefined;
return dataParts.join("\n");
}
// ---------------------------------------------------------------------------
// 1. OpenAI SSE
// ---------------------------------------------------------------------------
/**
* Collapse OpenAI Chat Completions SSE stream into a single response.
*
* Format:
* data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"Hello"}}]}\n\n
* data: [DONE]\n\n
*/
export function collapseOpenAISSE(rawBody: string): CollapseResult {
const inputTruncated = isCollapseInputTruncated(rawBody);
const body = guardCollapseBody(rawBody);
const lines = splitSSEEvents(body);
let content = "";
let reasoning = "";
const webSearchQueries: string[] = [];
let droppedChunks = 0;
let firstDroppedSample: string | undefined;
let harmonyUnparsed = false;
let harmonyNote: string | undefined;
const toolCallMap = new Map<number, { id: string; name: string; arguments: string }>();
// Fallback keying for deltas that OMIT `index`. Without this, every
// index-less delta collapses under one `undefined`/NaN key, merging distinct
// tool calls and corrupting arguments. Index-less fragments that share an
// `id` correlate via `idKeyMap`; otherwise each gets a fresh synthetic key
// assigned from a counter kept above any real index so sort order is stable.
// The 1_000_000 sentinel assumes real provider tool-call indices stay below
// it (they are small per-stream counters), so synthetic keys never collide.
let nextSyntheticIndex = 1_000_000;
const idKeyMap = new Map<string, number>();
// Cross-channel order atoms (#274), in stream arrival order. A toolCall atom
// references the same accumulator object stored in toolCallMap, so later arg
// deltas mutate the block in place.
const orderAtoms: OrderAtom[] = [];
for (const line of lines) {
const data = extractSSEData(splitSSELines(line));
if (data === undefined) continue;
const payload = data.trim();
if (payload === "[DONE]") continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(payload) as Record<string, unknown>;
} catch (err) {
droppedChunks++;
if (droppedChunks === 1) {
const msg = err instanceof Error ? err.message : "unknown";
firstDroppedSample = `parse failed (${msg}): ${surrogateSafeSlice(payload, 200)}`;
}
continue;
}
// Responses API reasoning events
if (
parsed.type === "response.reasoning_summary_text.delta" &&
typeof parsed.delta === "string"
) {
reasoning += parsed.delta;
continue;
}
// Responses API web search events
if (parsed.type === "response.output_item.done") {
const item = parsed.item as Record<string, unknown> | undefined;
if (item?.type === "web_search_call") {
const action = item.action as Record<string, unknown> | undefined;
if (action && typeof action.query === "string") {
webSearchQueries.push(action.query);
continue;
}
}
}
// Responses API text content events
if (parsed.type === "response.output_text.delta" && typeof parsed.delta === "string") {
content += parsed.delta;
continue;
}
// Skip other Responses API structural events
if (typeof parsed.type === "string" && parsed.type.startsWith("response.")) {
continue;
}
const choices = parsed.choices as Array<Record<string, unknown>> | undefined;
if (!choices || choices.length === 0) continue;
const delta = choices[0].delta as Record<string, unknown> | undefined;
if (!delta) continue;
// Reasoning content (OpenRouter / chat completions format)
if (typeof delta.reasoning_content === "string") {
reasoning += delta.reasoning_content;
}
// Text content
if (typeof delta.content === "string") {
content += delta.content;
if (delta.content.length > 0) {
orderAtoms.push({ kind: "text", text: delta.content });
}
}
// Tool calls
const toolCalls = delta.tool_calls as Array<Record<string, unknown>> | undefined;
if (toolCalls) {
for (const tc of toolCalls) {
const fn = tc.function as Record<string, unknown> | undefined;
const rawId = typeof tc.id === "string" ? tc.id : undefined;
// Resolve a stable map key. Prefer the streamed `index`; when it is
// absent, correlate by `id` if present, else mint a fresh synthetic
// key so distinct index-less calls never merge.
let index: number;
if (typeof tc.index === "number") {
index = tc.index;
} else if (rawId !== undefined) {
const existing = idKeyMap.get(rawId);
if (existing !== undefined) {
index = existing;
} else {
index = nextSyntheticIndex++;
idKeyMap.set(rawId, index);
}
} else {
index = nextSyntheticIndex++;
}
if (!toolCallMap.has(index)) {
const created = {
id: rawId ?? "",
name: (fn?.name as string) ?? "",
arguments: "",
};
toolCallMap.set(index, created);
// Record the tool atom at the position its FIRST delta arrived; it
// references `created` so later name/arg deltas fill it in place.
orderAtoms.push({ kind: "toolCall", ref: created });
}
const entry = toolCallMap.get(index)!;
if (fn?.name && typeof fn.name === "string" && !entry.name) {
entry.name = fn.name;
}
if (tc.id && typeof tc.id === "string" && !entry.id) {
entry.id = tc.id;
}
if (fn?.arguments && typeof fn.arguments === "string") {
entry.arguments += fn.arguments;
}
}
}
}
// Open-weight gpt-oss models (Ollama / vLLM / OpenRouter) stream tool calls
// as raw harmony channel tokens inside delta.content rather than structured
// delta.tool_calls. Harmony parsing is FALLBACK-ONLY: attempt it ONLY when
// there are NO structured delta.tool_calls. If structured tool calls exist,
// any harmony-looking content is prose — never merged (no phantom tool call),
// never stamped as truncated/dropped. When harmony IS the only source, a
// successful parse routes channels (content/reasoning/toolCalls); a failure
// preserves content VERBATIM and surfaces the distinct `harmonyUnparsed`
// signal (NOT droppedChunks/truncated — the bytes are not lost).
const harmonyToolCalls: ToolCall[] = [];
if (toolCallMap.size === 0 && isHarmonyContent(content)) {
const parsed = parseHarmonyContent(content);
if (parsed.failed) {
harmonyUnparsed = true;
harmonyNote = `harmony tokens present but unparseable; content preserved verbatim: ${surrogateSafeSlice(content, 200)}`;
} else {
content = parsed.content;
if (parsed.reasoning) {
reasoning += parsed.reasoning;
}
harmonyToolCalls.push(...parsed.toolCalls);
}
}
if (toolCallMap.size > 0 || harmonyToolCalls.length > 0) {
const blocks = buildOrderedBlocks(orderAtoms);
// When the stream is interleaved we persist ordered `blocks`; the flat
// `toolCalls` MUST then describe the same calls in the same order so the two
// representations never disagree (#274). The toolCall atoms reference the
// same accumulator objects as `toolCallMap`, so derive the flat list from
// those atoms (stream-arrival order, matching blocks) when blocks exist;
// otherwise keep the legacy index-sorted order for byte-identical fixtures.
const orderedToolCalls = orderAtoms
.filter(
(a): a is { kind: "toolCall"; ref: { name: string; arguments: string; id?: string } } =>
a.kind === "toolCall",
)
.map((a) => ({
name: a.ref.name,
arguments: normalizeToolArguments(a.ref.arguments),
...(a.ref.id ? { id: a.ref.id } : {}),
}));
const indexSortedToolCalls = Array.from(toolCallMap.entries())
.sort(([a], [b]) => a - b)
.map(([, tc]) => ({
name: tc.name,
arguments: normalizeToolArguments(tc.arguments),
...(tc.id ? { id: tc.id } : {}),
}));
return {
...(blocks ? { blocks } : {}),
...(content ? { content } : {}),
// Fallback-only: harmonyToolCalls are populated ONLY in the
// no-structured-calls branch, so this is never a merge of both sources.
toolCalls: [...(blocks ? orderedToolCalls : indexSortedToolCalls), ...harmonyToolCalls],
// Reasoning is preserved alongside tool calls for ALL structured streams
// (DeepSeek/OpenRouter reasoning_content, harmony analysis channel), at
// parity with every other collapser and the non-streaming path.
...(reasoning ? { reasoning } : {}),
// webSearches parity with the text-only return branch.
...(webSearchQueries.length > 0 ? { webSearches: webSearchQueries } : {}),
...(droppedChunks > 0 ? { droppedChunks } : {}),
...(firstDroppedSample ? { firstDroppedSample } : {}),
...(harmonyUnparsed ? { harmonyUnparsed: true } : {}),
...(harmonyNote ? { harmonyNote } : {}),
...(inputTruncated ? { truncated: true } : {}),
};
}
return {
content,
...(reasoning ? { reasoning } : {}),
...(webSearchQueries.length > 0 ? { webSearches: webSearchQueries } : {}),
...(droppedChunks > 0 ? { droppedChunks } : {}),
...(firstDroppedSample ? { firstDroppedSample } : {}),
...(harmonyUnparsed ? { harmonyUnparsed: true } : {}),
...(harmonyNote ? { harmonyNote } : {}),
...(inputTruncated ? { truncated: true } : {}),
};
}
// ---------------------------------------------------------------------------
// 2. Anthropic SSE
// ---------------------------------------------------------------------------
/**
* Collapse Anthropic Claude Messages SSE stream into a single response.
*
* Format:
* event: message_start\ndata: {...}\n\n
* event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":"Hello"}}\n\n
*/
export function collapseAnthropicSSE(rawBody: string): CollapseResult {
const inputTruncated = isCollapseInputTruncated(rawBody);
const body = guardCollapseBody(rawBody);
const blocks = splitSSEEvents(body);
let content = "";
let reasoning = "";
// Real cryptographic signature captured from a `signature_delta`; stays
// undefined when the stream carried none (e.g. aimock's own placeholder turns
// or non-thinking turns). Carried so a recorded real-provider thinking turn
// can replay its ACTUAL signature instead of aimock's placeholder.
let reasoningSignature: string | undefined;
// Opaque `data` payloads of any `redacted_thinking` content blocks, in stream
// order. Stays empty when none present. Carried so a recorded redacted-thinking
// turn round-trips its encrypted reasoning faithfully on replay.
const redactedThinking: string[] = [];
let droppedChunks = 0;
let firstDroppedSample: string | undefined;
const toolCallMap = new Map<number, { id: string; name: string; arguments: string }>();
// Fallback keying for content blocks that OMIT `index` (mirrors the OpenAI /
// Cohere / Bedrock guards). Without it, every index-less block collapses
// under one `undefined` key, merging distinct tool_use blocks. Index-less
// starts mint a fresh synthetic key (kept above any real index so sort order
// is stable). Despite its name, `lastSyntheticIndex` tracks whichever
// tool_use start most recently opened REGARDLESS of whether its index was
// real or synthetic (it is set on every tool_use start; thinking /
// redacted_thinking starts do not touch it), so an index-less delta
// correlates to the most-recent tool_use start — not just to the last
// synthetic one. The 1_000_000 sentinel assumes real provider indices stay
// below it.
let nextSyntheticIndex = 1_000_000;
let lastSyntheticIndex: number | undefined;
// Cross-channel order atoms (#274), in stream arrival order.
const orderAtoms: OrderAtom[] = [];
for (const block of blocks) {
const lines = splitSSELines(block);
const eventLine = lines.find((l) => l.startsWith("event:"));
const data = extractSSEData(lines);
if (data === undefined) continue;
const eventType = eventLine ? eventLine.slice(6).trim() : "";
const payload = data.trim();
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(payload) as Record<string, unknown>;
} catch (err) {
droppedChunks++;
if (droppedChunks === 1) {
const msg = err instanceof Error ? err.message : "unknown";
firstDroppedSample = `parse failed (${msg}): ${surrogateSafeSlice(payload, 200)}`;
}
continue;
}
if (eventType === "content_block_start") {
const contentBlock = parsed.content_block as Record<string, unknown> | undefined;
// A `redacted_thinking` block carries its encrypted reasoning in an opaque
// `data` string on the start event (no deltas follow). Capture it so the
// recorded turn can replay the redacted block faithfully
// (see capturedRedactedData for the non-empty rule).
const redactedData = capturedRedactedData(contentBlock);
if (redactedData !== undefined) {
redactedThinking.push(redactedData);
}
if (contentBlock?.type === "tool_use") {
// Prefer the streamed `index`; when absent, mint a fresh synthetic key
// so distinct index-less tool_use blocks never merge.
let index: number;
if (typeof parsed.index === "number") {
index = parsed.index;
} else {
index = nextSyntheticIndex++;
}
lastSyntheticIndex = index;
const created = {
id: (contentBlock.id as string) ?? "",
name: (contentBlock.name as string) ?? "",
arguments: "",
};
toolCallMap.set(index, created);
// Record the tool atom at the position the tool_use block opened; it
// references `created` so later input_json_delta fragments fill it in.
orderAtoms.push({ kind: "toolCall", ref: created });
}
}
if (eventType === "content_block_delta") {
const delta = parsed.delta as Record<string, unknown> | undefined;
if (!delta) continue;
if (delta.type === "text_delta" && typeof delta.text === "string") {
content += delta.text;
if (delta.text.length > 0) {
orderAtoms.push({ kind: "text", text: delta.text });
}
}
if (delta.type === "thinking_delta" && typeof delta.thinking === "string") {
reasoning += delta.thinking;
}
// The real cryptographic signature arrives via a trailing
// `signature_delta` (the `content_block_start` carried ""). Capture the
// last one seen so a recorded thinking turn replays its actual signature.
// Last-signature-wins: a turn with MULTIPLE thinking blocks overwrites this
// on each block, so the merged `reasoning` string ends up bound only to the
// FINAL block's signature — per-block signatures are not preserved.
if (delta.type === "signature_delta" && typeof delta.signature === "string") {
reasoningSignature = delta.signature;
}
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
// Use the streamed `index` when present; otherwise correlate to the
// most recent tool_use start (mirrors the start-side fallback).
const index = typeof parsed.index === "number" ? parsed.index : lastSyntheticIndex;
// A delta that cannot correlate to any known start (no streamed index
// AND no prior start, or a stale index with no entry) would otherwise
// silently lose its args. Account for it as a dropped chunk instead of
// vanishing (mirrors the Cohere uncorrelated-delta path).
const entry = index !== undefined ? toolCallMap.get(index) : undefined;
if (entry) {
entry.arguments += delta.partial_json;
} else {
droppedChunks++;
if (droppedChunks === 1) {
firstDroppedSample = `input_json_delta with no correlating tool_use start: ${surrogateSafeSlice(
payload,
200,
)}`;
}
}
}
}
}
if (toolCallMap.size > 0) {
const orderedBlocks = buildOrderedBlocks(orderAtoms);
// When interleaved (`blocks` present) the flat `toolCalls` MUST match the
// blocks' order/identity (#274). The toolCall atoms reference the same
// accumulator objects as `toolCallMap`, so derive the flat list from those
// atoms (stream-arrival order) when blocks exist; otherwise keep the legacy
// index-sorted order for byte-identical fixtures.
const orderedToolCalls = orderAtoms
.filter(
(a): a is { kind: "toolCall"; ref: { name: string; arguments: string; id?: string } } =>
a.kind === "toolCall",
)
.map((a) => ({
name: a.ref.name,
arguments: normalizeToolArguments(a.ref.arguments),
...(a.ref.id ? { id: a.ref.id } : {}),
}));
const indexSortedToolCalls = Array.from(toolCallMap.entries())
.sort(([a], [b]) => a - b)
.map(([, tc]) => ({
name: tc.name,
arguments: normalizeToolArguments(tc.arguments),
...(tc.id ? { id: tc.id } : {}),
}));
return {
...(orderedBlocks ? { blocks: orderedBlocks } : {}),
...(content ? { content } : {}),
toolCalls: orderedBlocks ? orderedToolCalls : indexSortedToolCalls,
...(reasoning ? { reasoning } : {}),
...(reasoningSignature ? { reasoningSignature } : {}),
...(redactedThinking.length > 0 ? { redactedThinking } : {}),
...(droppedChunks > 0 ? { droppedChunks } : {}),
...(firstDroppedSample ? { firstDroppedSample } : {}),
...(inputTruncated ? { truncated: true } : {}),
};
}
return {
content,
...(reasoning ? { reasoning } : {}),
...(reasoningSignature ? { reasoningSignature } : {}),
...(redactedThinking.length > 0 ? { redactedThinking } : {}),
...(droppedChunks > 0 ? { droppedChunks } : {}),
...(firstDroppedSample ? { firstDroppedSample } : {}),
...(inputTruncated ? { truncated: true } : {}),
};
}
// ---------------------------------------------------------------------------
// 3. Gemini SSE
// ---------------------------------------------------------------------------
/**
* Collapse Gemini SSE stream into a single response.
*
* Format (data-only, no event prefix, no [DONE]):
* data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}\n\n
*/
export function collapseGeminiSSE(rawBody: string): CollapseResult {
const inputTruncated = isCollapseInputTruncated(rawBody);
const body = guardCollapseBody(rawBody);
const lines = splitSSEEvents(body);
let content = "";
let reasoning = "";
let droppedChunks = 0;
let firstDroppedSample: string | undefined;
let audioB64 = "";
let audioMimeType: string | undefined;
const toolCalls: ToolCall[] = [];
// Cross-channel order atoms (#274), in stream arrival order.
const orderAtoms: OrderAtom[] = [];
for (const line of lines) {
const data = extractSSEData(splitSSELines(line));
if (data === undefined) continue;
const payload = data.trim();
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(payload) as Record<string, unknown>;
} catch (err) {
droppedChunks++;
if (droppedChunks === 1) {
const msg = err instanceof Error ? err.message : "unknown";
firstDroppedSample = `parse failed (${msg}): ${surrogateSafeSlice(payload, 200)}`;
}
continue;
}
const candidates = parsed.candidates as Array<Record<string, unknown>> | undefined;
if (!candidates || candidates.length === 0) continue;
const candidateContent = candidates[0].content as Record<string, unknown> | undefined;
if (!candidateContent) continue;
const parts = candidateContent.parts as Array<Record<string, unknown>> | undefined;
if (!parts || parts.length === 0) continue;
for (const part of parts) {
if (part.functionCall) {
const fc = part.functionCall as Record<string, unknown>;
const created: ToolCall = {
name: String(fc.name ?? ""),
// Default undefined/object args to a JSON object string (matches
// collapseGeminiInteractionsSSE / Ollama). JSON.stringify(undefined)
// would otherwise yield the VALUE undefined, violating the
// ToolCall.arguments:string contract.
arguments:
typeof fc.args === "string" ? (fc.args as string) : JSON.stringify(fc.args ?? {}),
};
toolCalls.push(created);
// Record the tool atom at the position this functionCall part arrived.
orderAtoms.push({ kind: "toolCall", ref: created });
} else if (
part.inlineData &&
typeof (part.inlineData as Record<string, unknown>).mimeType === "string" &&
((part.inlineData as Record<string, unknown>).mimeType as string).startsWith("audio/")
) {
const inlineData = part.inlineData as Record<string, unknown>;
if (!audioMimeType) {
audioMimeType = inlineData.mimeType as string;
}
if (typeof inlineData.data === "string") {
audioB64 += inlineData.data;
}
} else if (typeof part.text === "string") {
if (part.thought) {
reasoning += part.text;
} else {
content += part.text;
if (part.text.length > 0) {
orderAtoms.push({ kind: "text", text: part.text });
}
}
}
}
}
// Normalize the flat tool calls' arguments identically to the block path so
// the two representations never disagree (#274). The toolCall atoms reference
// the same `created` objects pushed here, so blocks and flat describe the same
// calls in the same order; this only reconciles empty/missing → "{}".
const normalizedToolCalls = toolCalls.map((tc) => ({
...tc,
arguments: normalizeToolArguments(tc.arguments),
}));
if (audioB64) {
// Preserve any content / reasoning / tool calls accumulated in the same
// stream — a Gemini turn can interleave audio with text and functionCall
// parts, and the early return must not silently drop them.
//
// Deliberately do NOT build ordered `blocks` here (#274, R2-N2): the audio
// collapse shape maps to AudioResponse, which has no `blocks` slot, and the
// recorder's audio branch never persists `collapsed.blocks`. Producing block
// ordering on this path would be silently produced-then-dropped, advertising
// a field this result shape can't carry. Block ordering is built only on the
// content+toolCalls path below, which can actually carry it.
return {
audioB64,
audioMimeType,
...(content ? { content } : {}),
...(reasoning ? { reasoning } : {}),
...(normalizedToolCalls.length > 0 ? { toolCalls: normalizedToolCalls } : {}),
...(droppedChunks > 0 ? { droppedChunks } : {}),
...(firstDroppedSample ? { firstDroppedSample } : {}),
...(inputTruncated ? { truncated: true } : {}),
};
}
if (toolCalls.length > 0) {
const blocks = buildOrderedBlocks(orderAtoms);
return {
...(blocks ? { blocks } : {}),
...(content ? { content } : {}),
toolCalls: normalizedToolCalls,
...(reasoning ? { reasoning } : {}),
...(droppedChunks > 0 ? { droppedChunks } : {}),
...(firstDroppedSample ? { firstDroppedSample } : {}),
...(inputTruncated ? { truncated: true } : {}),
};
}
return {
content,
...(reasoning ? { reasoning } : {}),
...(droppedChunks > 0 ? { droppedChunks } : {}),
...(firstDroppedSample ? { firstDroppedSample } : {}),
...(inputTruncated ? { truncated: true } : {}),
};
}
// ---------------------------------------------------------------------------
// 4. Ollama NDJSON
// ---------------------------------------------------------------------------
/**
* Collapse Ollama NDJSON stream into a single response.
*
* /api/chat format:
* {"model":"llama3","message":{"role":"assistant","content":"Hello"},"done":false}\n
*
* /api/generate format:
* {"model":"llama3","response":"Hello","done":false}\n
*
* Open-weight gpt-oss served via Ollama streams harmony channel tokens inside
* `message.content` (just like the OpenAI SSE path), so after accumulation the
* content is run through the same fail-safe {@link parseHarmonyContent} gate to
* capture structured tool calls / reasoning instead of leaking raw tokens.
*/
export function collapseOllamaNDJSON(rawBody: string): CollapseResult {
const inputTruncated = isCollapseInputTruncated(rawBody);
const body = guardCollapseBody(rawBody);
const lines = body.split("\n").filter((l) => l.trim().length > 0);
let content = "";
let reasoning = "";
let droppedChunks = 0;
let firstDroppedSample: string | undefined;
let harmonyUnparsed = false;
let harmonyNote: string | undefined;
const toolCalls: ToolCall[] = [];
// Cross-channel order atoms (#274), in stream arrival order.
const orderAtoms: OrderAtom[] = [];
for (const line of lines) {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(line.trim()) as Record<string, unknown>;
} catch (err) {
droppedChunks++;
if (droppedChunks === 1) {
const msg = err instanceof Error ? err.message : "unknown";
firstDroppedSample = `parse failed (${msg}): ${surrogateSafeSlice(line.trim(), 200)}`;
}
continue;
}
// /api/chat format
const message = parsed.message as Record<string, unknown> | undefined;
if (message) {
if (typeof message.content === "string") {
content += message.content;
if (message.content.length > 0) {
orderAtoms.push({ kind: "text", text: message.content });
}
}
// Tool calls
if (Array.isArray(message.tool_calls)) {
for (const tc of message.tool_calls as Array<Record<string, unknown>>) {
const fn = tc.function as Record<string, unknown> | undefined;
if (fn) {
const created: ToolCall = {
name: String(fn.name ?? ""),
// Default undefined/object args to a JSON object (matching
// collapseGeminiInteractionsSSE) — JSON.stringify(undefined)
// would otherwise yield the literal string "undefined".
arguments:
typeof fn.arguments === "string"
? fn.arguments
: JSON.stringify(fn.arguments ?? {}),
};
toolCalls.push(created);
orderAtoms.push({ kind: "toolCall", ref: created });
}
}