bunnyquery 1.9.7 → 1.9.10

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.
@@ -0,0 +1,1054 @@
1
+ /**
2
+ * Streamed-turn parser: raw provider SSE bytes in, live answer text + the body a
3
+ * buffered call would have returned out.
4
+ *
5
+ * WHY THIS FILE EXISTS, AND WHY IT IS HERE AND NOT IN SKAPI.
6
+ * skapi's clientSecretRequest is a byte relay. On a streamed turn the worker reads
7
+ * the destination's response incrementally and appends the raw bytes to a chunk
8
+ * table; it settles the polling row with STATUS ONLY, no body, because the content
9
+ * lives in the chunks. skapi therefore does not know that Anthropic or OpenAI
10
+ * exist, has no dialect list, and parses nothing. BunnyQuery is the party that
11
+ * knows which destination it dialled, so BunnyQuery is the party that parses, and
12
+ * this module is the whole of that knowledge.
13
+ *
14
+ * WHAT ARRIVES. csr-poll hands back `chunks: [{seq, txt}]` (ascending, seq starts
15
+ * at 1) plus `last_seq` to send back as the next `since`. A chunk is whatever the
16
+ * worker's flush happened to contain: it flushes on a byte cap or a time interval,
17
+ * so a chunk boundary lands wherever the socket broke, which is routinely in the
18
+ * MIDDLE of an SSE frame. Half a frame is not data, so every partial is held in a
19
+ * buffer until the rest arrives, and nothing is ever emitted from an incomplete
20
+ * frame. That is what makes this a stateful object fed chunks rather than a
21
+ * function over a whole transcript.
22
+ *
23
+ * REPLAY SAFETY. The parser is a pure function of the chunk SEQUENCE, so a reload
24
+ * or a second tab that starts at seq 1 of a stream it did not initiate rebuilds
25
+ * the identical state. Nothing here depends on having dispatched the request.
26
+ *
27
+ * THE TWO OUTPUTS, AND WHY BOTH ARE NEEDED.
28
+ * text the assistant's answer ONLY, for rendering as it arrives. Not tool
29
+ * arguments, not thinking. Concatenating every delta into one string
30
+ * is how a half serialised tool call ends up in the middle of a
31
+ * user's sentence.
32
+ * finalBody() the assembled provider body, byte equivalent to the buffered
33
+ * response, so extractClaudeText / extractOpenAIText (requests.ts)
34
+ * produce the identical string whether the turn was read live or
35
+ * re-read from history later.
36
+ *
37
+ * THE BUG THIS IS SHAPED AROUND. extractClaudeText joins TEXT BLOCKS with '\n':
38
+ *
39
+ * content.filter(b => b.type === 'text').map(b => b.text).join('\n')
40
+ *
41
+ * A server-tool turn has text at content index 0, the tool call at 1, its result
42
+ * at 2, and text again at 3. Accumulate every text_delta into one string and those
43
+ * two paragraphs fuse with no separator, so the answer the user watched arrive and
44
+ * the same turn re-read from history are different strings. Blocks are therefore
45
+ * keyed BY INDEX and never merged, and `text` is the join of the text blocks in
46
+ * index order, which is exactly the extractor's rule and not an approximation of
47
+ * it. See tests/sse-stream.cjs, "four separate blocks".
48
+ *
49
+ * NEVER THROWS FROM feed(). Chunks arrive on a poll tick, inside a timer the
50
+ * consumer cannot reasonably wrap; a parse error there would take the whole poll
51
+ * down over one malformed frame. Frames that cannot be understood are counted
52
+ * (`malformedFrames`) and skipped.
53
+ *
54
+ * HONEST TERMINATION, AND WHY IT TAKES TWO FLAGS. `complete` means a terminal event
55
+ * actually arrived. A stream that was cut (deadline, cancelled row, a worker crash
56
+ * after some chunks landed) reports complete:false, and the caller must not present
57
+ * it as a finished answer. A partial answer the reader can see beats an empty turn,
58
+ * but only if it is labelled as partial.
59
+ *
60
+ * That flag alone used to be read as "the answer is whole", and it is not the same
61
+ * claim. An `error` frame IS a terminal event: the provider said the stream is over
62
+ * and nothing more is coming. But the text in hand is only whatever arrived before
63
+ * the error, so the answer is TRUNCATED and the stream is FINISHED at the same
64
+ * time. A caller whose finalize gate read `complete` therefore stored the
65
+ * truncation as the turn's permanent history and, because finalize is also the only
66
+ * way to release chunks, deleted the only copy of the bytes in the same call - for
67
+ * a turn the provider had explicitly told it went wrong. So the two claims are two
68
+ * fields:
69
+ *
70
+ * complete a terminal event arrived. Nothing more is coming; stop waiting.
71
+ * answerComplete ...and it was a terminal event that means the answer FINISHED
72
+ * (message_stop, response.completed, response.incomplete), not
73
+ * one that means it DIED (an `error` frame, response.failed, a
74
+ * terminal Response carrying an error payload).
75
+ *
76
+ * `response.incomplete` is deliberately on the finished side: the model stopped
77
+ * short at max_output_tokens, but the terminal event carries the complete Response
78
+ * document, so the chunks hold nothing the body does not. A caller deciding what to
79
+ * keep reads `answerComplete`; a caller deciding whether to keep waiting reads
80
+ * `complete`.
81
+ *
82
+ * WHEN THE BYTES ARE NOT SSE AT ALL. skapi's `stream: true` tells the RELAY to read
83
+ * the response incrementally. It does not tell the destination to produce an event
84
+ * stream: that is the caller's own request body. If the body never asked for one
85
+ * (or something in front of the destination buffers the stream back into a single
86
+ * document and drops the framing), the answer arrives as a plain JSON body with not
87
+ * one `data:` line in it. Every frame test below then matches nothing, and the turn
88
+ * used to end as an empty answer with malformedFrames 0, complete false and
89
+ * finalBody() null: the entire reply lost, with nothing in the output saying so, so
90
+ * a client draws an empty bubble and no error. That state is now reported as
91
+ * `unframed`, and the bytes are handed back BOTH ways, because the parser cannot
92
+ * tell an answer from a gateway's error page without knowing the vendor, and it
93
+ * must not:
94
+ * unframedText the bytes verbatim, for a body that is not JSON at all (an HTML
95
+ * 502 page), which finalBody() cannot represent.
96
+ * finalBody() the parsed document when the bytes ARE JSON, because a buffered
97
+ * body is exactly what finalBody() promises, so the caller's
98
+ * existing buffered path (isErrorResponseBody, extractClaudeText,
99
+ * extractOpenAIText) reads it with no new branch at all.
100
+ * Noticing that a byte stream carries no SSE framing is framing, not parsing, and
101
+ * JSON.parse is the same transport-level codec this file already runs on every
102
+ * frame payload. Nothing about the document is interpreted: it is handed over
103
+ * whole, and `provider` stays null because no event ever identified one.
104
+ *
105
+ * DOM-free and framework-free like the rest of the engine.
106
+ */
107
+
108
+ /** One row of csr-poll's `chunks`. */
109
+ export interface SseChunk {
110
+ seq: number;
111
+ txt: string;
112
+ }
113
+
114
+ /**
115
+ * Which grammar the bytes turned out to be in. Detected, never declared: see
116
+ * detectProvider() below for why the caller is not asked.
117
+ */
118
+ export type SseProvider = 'claude' | 'openai';
119
+
120
+ /**
121
+ * A tool the model reached for, in the order it appeared, so a "querying sales
122
+ * table..." row can be drawn before a single character of answer text exists.
123
+ * Duplicates are kept: two calls to the same tool are two rows, not one.
124
+ */
125
+ export interface SseToolCall {
126
+ /** Anthropic content index, or OpenAI output index. Identifies the block. */
127
+ index: number;
128
+ /** The name as the provider wrote it, falling back to the block/item type for
129
+ * a built-in that carries no name of its own (OpenAI's web_search_call). */
130
+ name: string;
131
+ /** The provider's own block/item type: tool_use, server_tool_use,
132
+ * mcp_tool_use, function_call, mcp_call, web_search_call, ... */
133
+ type: string;
134
+ /** Present on Anthropic mcp_tool_use only. */
135
+ serverName?: string;
136
+ }
137
+
138
+ export interface SseSnapshot {
139
+ /** null until the first identifying event has been seen. */
140
+ provider: SseProvider | null;
141
+ /** The assistant's answer text so far, joined exactly as the extractor joins
142
+ * it. Never contains tool arguments or thinking. */
143
+ text: string;
144
+ /** Extended-thinking text so far, for a "thinking..." affordance. Deliberately
145
+ * a SEPARATE field: it must never be concatenated into `text`. Populated on
146
+ * BOTH providers (Anthropic thinking blocks, OpenAI reasoning summary and
147
+ * reasoning text deltas). It used to be Anthropic-only, which meant the field
148
+ * read as "the model's thinking" on one provider and as "this model did not
149
+ * think" on the other, and every consumer that did not branch on `provider`
150
+ * drew the wrong thing. Absorbing exactly that branch is what this module is
151
+ * for, so the field is filled rather than renamed. */
152
+ thinkingText: string;
153
+ /** Tools reached for, in order of appearance. */
154
+ toolCalls: SseToolCall[];
155
+ /** Convenience projection of toolCalls, same order, duplicates kept. */
156
+ toolNames: string[];
157
+ /** Anthropic stop_reason ('end_turn' | 'tool_use' | 'max_tokens' | ...), or for
158
+ * OpenAI the terminal Response's status, or its incomplete_details.reason when
159
+ * it stopped short ('max_output_tokens'). null until the stream says. */
160
+ stopReason: string | null;
161
+ /** A terminal event ARRIVED. False means the stream was cut and whatever is
162
+ * here is partial: do not present it as a finished answer.
163
+ *
164
+ * THIS IS NOT THE FLAG TO STORE BY. It answers "is anything more coming?", not
165
+ * "is this the whole answer?" - see `answerComplete`. */
166
+ complete: boolean;
167
+ /** The terminal event that arrived means the answer FINISHED, not that it DIED.
168
+ *
169
+ * True on message_stop, response.completed and response.incomplete; false while
170
+ * the stream is still running, false when it was cut, and false when it ended
171
+ * on an `error` frame, on response.failed, or on a terminal Response carrying
172
+ * an error payload.
173
+ *
174
+ * THE FAILURE THIS FIELD EXISTS FOR. An `error` frame sets `terminalEvent`, so
175
+ * `complete` goes true while the text is only what arrived before the error. A
176
+ * caller that finalizes on `complete` therefore writes that truncation into the
177
+ * turn's permanent history AND releases the chunks it was assembled from, which
178
+ * is the one loss in this feature that cannot be undone. Every keep/store gate
179
+ * reads THIS field; `complete` is for deciding whether to keep waiting. Bytes
180
+ * that were never SSE at all reach neither: see `unframed`, where it is the
181
+ * polling row's status and not the parse that says the response finished. */
182
+ answerComplete: boolean;
183
+ /** The exact terminal event: 'message_stop', 'response.completed',
184
+ * 'response.incomplete', 'response.failed', 'error'. null while running. */
185
+ terminalEvent: string | null;
186
+ /** The stream ended in a provider error. */
187
+ errored: boolean;
188
+ /** The provider's error payload, in the shape isErrorResponseBody() detects. */
189
+ error: any;
190
+ /** Frames that could not be parsed, and tool-argument JSON that would not
191
+ * parse at content_block_stop. Diagnostics: both are zero on a healthy turn. */
192
+ malformedFrames: number;
193
+ malformedToolJson: number;
194
+ /** Bytes were relayed, end() was called, and NOT ONE of them was SSE framing:
195
+ * no `data:`, no `event:`, not even a comment. The destination answered with a
196
+ * plain body instead of an event stream. This is NOT malformedFrames: there
197
+ * were no frames to mangle. Nothing is lost, `unframedText` is the bytes and
198
+ * finalBody() is the parsed document when they are JSON, so the caller can
199
+ * either render them through its buffered path or surface a real error.
200
+ * `complete` stays false here because no terminal EVENT arrived and none ever
201
+ * will: on an unframed body it is the polling row's own status, not the bytes,
202
+ * that says whether the response finished. */
203
+ unframed: boolean;
204
+ /** The relayed bytes verbatim when `unframed`, else null. */
205
+ unframedText: string | null;
206
+ /** Highest chunk seq accepted, for the caller's `since` cursor. 0 = none. */
207
+ lastSeq: number;
208
+ }
209
+
210
+ export interface SseParser {
211
+ /** Feed raw bytes. Any prefix of a frame is held until the rest arrives. */
212
+ feed(text: string): void;
213
+ /** Feed csr-poll's `chunks` array. Chunks at or below the highest seq already
214
+ * accepted are DROPPED, so a re-poll from a stale `since` cannot double-append
215
+ * the same bytes into the answer. */
216
+ feedChunks(chunks: SseChunk[] | null | undefined): void;
217
+ /** No more bytes are coming. Flushes a final frame that arrived without its
218
+ * terminating blank line. Does NOT mark the stream complete: only a terminal
219
+ * event does that. It IS what decides `unframed`, because up to this call
220
+ * "no framing seen yet" and "the first frame has not finished arriving" are
221
+ * the same state, so a caller that never calls end() never learns the bytes
222
+ * were not SSE. */
223
+ end(): void;
224
+ snapshot(): SseSnapshot;
225
+ /** The assembled provider body, byte equivalent to a buffered response, or
226
+ * null when nothing has been assembled. See buildBody() for the two rules:
227
+ * one about errors, one about bytes that were never SSE. */
228
+ finalBody(): any;
229
+ }
230
+
231
+ /* ── the grammars ────────────────────────────────────────────────────────── */
232
+
233
+ // Anthropic Messages streaming. 'error' is deliberately NOT here: OpenAI has an
234
+ // 'error' event too, so it identifies nothing and must not decide the provider.
235
+ var CLAUDE_EVENTS: Record<string, true> = {
236
+ message_start: true,
237
+ message_delta: true,
238
+ message_stop: true,
239
+ content_block_start: true,
240
+ content_block_delta: true,
241
+ content_block_stop: true,
242
+ ping: true,
243
+ };
244
+
245
+ // Anthropic content blocks that represent a tool being reached for. Listed rather
246
+ // than pattern-matched so a future block type cannot be mistaken for a tool call
247
+ // and drawn as a "querying..." row.
248
+ var CLAUDE_TOOL_BLOCKS: Record<string, true> = {
249
+ tool_use: true,
250
+ server_tool_use: true,
251
+ mcp_tool_use: true,
252
+ web_search_tool_use: true,
253
+ };
254
+
255
+ // OpenAI Responses output items that represent a tool being reached for.
256
+ var OPENAI_TOOL_ITEMS: Record<string, true> = {
257
+ function_call: true,
258
+ mcp_call: true,
259
+ web_search_call: true,
260
+ file_search_call: true,
261
+ code_interpreter_call: true,
262
+ computer_call: true,
263
+ image_generation_call: true,
264
+ };
265
+
266
+ /**
267
+ * The provider is DETECTED from the first identifying event rather than declared
268
+ * by the caller, for three reasons:
269
+ *
270
+ * 1. The chunks are the only evidence of what actually answered. A caller's
271
+ * belief about which provider it dispatched to is a second source of truth,
272
+ * and the failure when the two disagree is silent: the wrong grammar matches
273
+ * nothing, so the turn renders as an empty answer rather than as an error.
274
+ * 2. A reload or a second tab parses a stream it did not initiate. All it has is
275
+ * the polling row; requiring a declaration would mean plumbing the platform
276
+ * through every replay path just to restate what byte 1 already says.
277
+ * 3. It costs nothing. Both grammars carry `type` inside the data payload (and
278
+ * repeat it as the SSE `event:` name), and the two namespaces are disjoint:
279
+ * every OpenAI Responses event is dotted under 'response.', every Anthropic
280
+ * one is a bare underscore name.
281
+ */
282
+ function detectProvider(type: string): SseProvider | null {
283
+ if (!type) return null;
284
+ if (type.indexOf('response.') === 0) return 'openai';
285
+ if (CLAUDE_EVENTS[type]) return 'claude';
286
+ return null;
287
+ }
288
+
289
+ /* ── SSE framing ─────────────────────────────────────────────────────────── */
290
+
291
+ /**
292
+ * Where the line starting at `from` ends, or null when the buffer does not yet
293
+ * hold a complete line.
294
+ *
295
+ * A trailing lone '\r' returns null ON PURPOSE. It is ambiguous: it may be a
296
+ * classic-Mac line terminator, or it may be the first half of a '\r\n' whose '\n'
297
+ * is in the next chunk. Emitting the line now and meeting the '\n' next time would
298
+ * produce a spurious EMPTY line, and an empty line is the SSE frame separator, so
299
+ * one unlucky chunk boundary would split a frame in two and lose it. Holding it
300
+ * costs one chunk of latency and cannot be wrong.
301
+ */
302
+ function lineEnd(s: string, from: number): { at: number; len: number } | null {
303
+ for (var i = from; i < s.length; i++) {
304
+ var c = s.charCodeAt(i);
305
+ if (c === 10) return { at: i, len: 1 };
306
+ if (c === 13) {
307
+ if (i + 1 >= s.length) return null;
308
+ return { at: i, len: s.charCodeAt(i + 1) === 10 ? 2 : 1 };
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+
314
+ /**
315
+ * One SSE frame's fields. Per the spec: a line starting with ':' is a comment
316
+ * (keepalive), a line with no ':' is a field with an empty value, and exactly ONE
317
+ * leading space is stripped from the value. Multiple `data:` lines in one frame
318
+ * join with '\n', which matters because a provider is free to pretty-print.
319
+ *
320
+ * `framed` says whether ANY line here was SSE at all: a comment, or one of the
321
+ * four field names the spec defines. It is the evidence that these bytes really
322
+ * are an event stream, and it is deliberately wider than the two fields this
323
+ * module reads, because `id:`/`retry:`/a bare keepalive comment are framing even
324
+ * though nothing downstream wants their value. Quoting is what keeps a JSON body
325
+ * from tripping it: a pretty-printed document's lines read `"data": {...}` with
326
+ * the quote inside the field name, never a bare `data`.
327
+ */
328
+ function readFrame(lines: string[]): { event: string; data: string; framed: boolean } {
329
+ var event = '';
330
+ var data: string[] = [];
331
+ var framed = false;
332
+ for (var i = 0; i < lines.length; i++) {
333
+ var line = lines[i];
334
+ if (!line.length) continue;
335
+ if (line.charCodeAt(0) === 58 /* ':' */) {
336
+ framed = true;
337
+ continue;
338
+ }
339
+ var colon = line.indexOf(':');
340
+ var field = colon === -1 ? line : line.slice(0, colon);
341
+ var value = colon === -1 ? '' : line.slice(colon + 1);
342
+ if (value.charCodeAt(0) === 32) value = value.slice(1);
343
+ if (field === 'data') {
344
+ framed = true;
345
+ data.push(value);
346
+ } else if (field === 'event') {
347
+ framed = true;
348
+ event = value;
349
+ } else if (field === 'id' || field === 'retry') {
350
+ framed = true;
351
+ }
352
+ }
353
+ return { event: event, data: data.join('\n'), framed: framed };
354
+ }
355
+
356
+ /* ── the parser ──────────────────────────────────────────────────────────── */
357
+
358
+ interface ClaudeBlock {
359
+ /** The block as it will appear in the final body's content array. */
360
+ block: any;
361
+ /** input_json_delta accumulator. Parsed once, at content_block_stop. */
362
+ json: string;
363
+ sawJson: boolean;
364
+ }
365
+
366
+ export function createSseParser(): SseParser {
367
+ /* framing state */
368
+ var buf = '';
369
+ var lines: string[] = [];
370
+ var lastSeq = 0;
371
+
372
+ /* "were these bytes ever SSE?" state. `raw` accumulates everything fed UNTIL
373
+ * the first line that proves the stream is framed, and is dropped at that
374
+ * moment, so a healthy stream retains at most its first frame and a body that
375
+ * is not SSE retains the body, which is the size the buffered path would have
376
+ * held anyway. */
377
+ var sawFraming = false;
378
+ var raw = '';
379
+ var rawHasContent = false;
380
+ var ended = false;
381
+ /* finalBody() may be called on every render; the document is parsed once. */
382
+ var rawParsed = false;
383
+ var rawBody: any = null;
384
+
385
+ /* shared state */
386
+ var provider: SseProvider | null = null;
387
+ var terminalEvent: string | null = null;
388
+ var errored = false;
389
+ var error: any = null;
390
+ var stopReason: string | null = null;
391
+ var toolCalls: SseToolCall[] = [];
392
+ var malformedFrames = 0;
393
+ var malformedToolJson = 0;
394
+
395
+ /* claude state: blocks keyed BY INDEX, never merged. See the header. */
396
+ var message: any = null;
397
+ var blocks = new Map<number, ClaudeBlock>();
398
+
399
+ /* openai state: one entry per output_text PART, keyed by (output, content),
400
+ * because extractOpenAIText joins the parts with '\n' the same way
401
+ * extractClaudeText joins text blocks. */
402
+ var parts = new Map<string, { oi: number; ci: number; text: string }>();
403
+ /** OpenAI reasoning text, render-only. See putReasoning(). */
404
+ var reasoning = new Map<string, { oi: number; idx: number; kind: number; text: string }>();
405
+ /** The terminal Response object, verbatim. See handleOpenAI(). */
406
+ var response: any = null;
407
+
408
+ /* `text` is a join over state that changes on nearly every frame, so it is
409
+ * computed on demand and cached until something that feeds it moves. Recomputing
410
+ * per frame would be quadratic in a long answer for a value the consumer reads
411
+ * once per poll tick. */
412
+ var textCache: string | null = null;
413
+ var thinkingCache: string | null = null;
414
+
415
+ function feed(text: string): void {
416
+ if (typeof text !== 'string' || !text.length) return;
417
+ if (!sawFraming) {
418
+ // Held for the unframed fallback until framing proves it unnecessary. The
419
+ // content test short-circuits on the first non-space, so it costs nothing on
420
+ // a real chunk, and it is done here rather than at end() so that a caller
421
+ // that feeds again after settling (a poll that answered late) cannot be read
422
+ // against a stale scan or against a document parsed from a shorter prefix.
423
+ raw += text;
424
+ if (!rawHasContent) rawHasContent = /\S/.test(text);
425
+ rawParsed = false;
426
+ rawBody = null;
427
+ }
428
+ buf += text;
429
+ var i = 0;
430
+ for (;;) {
431
+ var end = lineEnd(buf, i);
432
+ if (!end) break;
433
+ var line = buf.slice(i, end.at);
434
+ i = end.at + end.len;
435
+ if (line.length === 0) dispatch();
436
+ else lines.push(line);
437
+ }
438
+ // Whatever is left is a partial line (or the ambiguous trailing '\r'), and it
439
+ // stays in the buffer until the chunk that completes it arrives.
440
+ if (i > 0) buf = buf.slice(i);
441
+ }
442
+
443
+ function feedChunks(chunks: SseChunk[] | null | undefined): void {
444
+ if (!chunks || !chunks.length) return;
445
+ for (var i = 0; i < chunks.length; i++) {
446
+ var c = chunks[i];
447
+ if (!c || typeof c !== 'object') continue;
448
+ var seq = typeof c.seq === 'number' ? c.seq : 0;
449
+ // A client that re-polls from a `since` it already consumed (a retry, a
450
+ // second poll racing the first, a cursor restored from a stale render)
451
+ // gets the same chunks again. Appending them a second time would duplicate
452
+ // a slab of the answer, which is invisible until the user reads it.
453
+ if (seq && seq <= lastSeq) continue;
454
+ if (seq > lastSeq) lastSeq = seq;
455
+ feed(typeof c.txt === 'string' ? c.txt : '');
456
+ }
457
+ }
458
+
459
+ function end(): void {
460
+ // Tolerate a final frame whose terminating blank line never arrived (a body
461
+ // that ended exactly on its last event, or a relay cut one byte early). The
462
+ // ambiguous trailing '\r' can be resolved now: nothing more is coming.
463
+ if (buf.length) {
464
+ var tail = buf.charCodeAt(buf.length - 1) === 13 ? buf.slice(0, -1) : buf;
465
+ if (tail.length) lines.push(tail);
466
+ buf = '';
467
+ }
468
+ // AFTER that flush, never before: a body that ends exactly on its last frame
469
+ // proves itself framed only in this final dispatch, and a stream is not
470
+ // settled until everything it sent has been read.
471
+ if (lines.length) dispatch();
472
+ ended = true;
473
+ }
474
+
475
+ /**
476
+ * Bytes arrived, the relay is done, and none of it was an event stream. Only
477
+ * decidable at end(): while bytes are still coming, "no framing yet" and "the
478
+ * first frame is still arriving" are the same state, and calling it early would
479
+ * hand the caller half a document as if it were a body.
480
+ *
481
+ * Whitespace alone is not a body. A stream that relayed nothing but newlines is
482
+ * an empty stream, not an unframed one, and reporting it as unframed would
483
+ * invite the caller to render blank bytes as an answer.
484
+ */
485
+ function isUnframed(): boolean {
486
+ return ended && !sawFraming && rawHasContent;
487
+ }
488
+
489
+ function dispatch(): void {
490
+ var pending = lines;
491
+ lines = [];
492
+ if (!pending.length) return;
493
+ // The whole point of never throwing: this runs on a poll tick. One frame the
494
+ // provider mangled (or a proxy truncated) must cost that frame and nothing else.
495
+ try {
496
+ var frame = readFrame(pending);
497
+ if (frame.framed && !sawFraming) {
498
+ // Settled the moment one SSE line is seen, and BEFORE the early returns
499
+ // below, so an `event:`-only frame or a lone keepalive comment still
500
+ // counts as proof. Everything held for the unframed fallback is dropped
501
+ // here: on a real stream it would only grow without ever being read.
502
+ sawFraming = true;
503
+ raw = '';
504
+ rawHasContent = false;
505
+ }
506
+ if (!frame.data.length) return;
507
+ // Some relays end a stream with a literal sentinel rather than an event.
508
+ // It is not JSON and it carries nothing, so it is not a malformed frame.
509
+ if (frame.data === '[DONE]') return;
510
+ var ev: any = JSON.parse(frame.data);
511
+ if (!ev || typeof ev !== 'object') {
512
+ malformedFrames++;
513
+ return;
514
+ }
515
+ // The `type` inside the payload is authoritative; the SSE `event:` name is
516
+ // the fallback for a provider that only names the frame in the header.
517
+ var type: string = typeof ev.type === 'string' && ev.type ? ev.type : frame.event;
518
+ if (!type) {
519
+ malformedFrames++;
520
+ return;
521
+ }
522
+ if (!provider) provider = detectProvider(type);
523
+ if (provider === 'openai') handleOpenAI(type, ev);
524
+ else if (provider === 'claude') handleClaude(type, ev);
525
+ else handleUnattributed(type, ev);
526
+ } catch (e) {
527
+ malformedFrames++;
528
+ }
529
+ }
530
+
531
+ /**
532
+ * A frame that arrived before anything identified the provider. In practice
533
+ * this is only ever the 'error' event, which both grammars spell the same way
534
+ * and which therefore must not decide the provider (see CLAUDE_EVENTS).
535
+ */
536
+ function handleUnattributed(type: string, ev: any): void {
537
+ if (type === 'error') {
538
+ takeError(ev && ev.error ? ev : { type: 'error', error: ev });
539
+ return;
540
+ }
541
+ malformedFrames++;
542
+ }
543
+
544
+ function takeError(payload: any): void {
545
+ errored = true;
546
+ terminalEvent = 'error';
547
+ error = payload;
548
+ }
549
+
550
+ /* ── Anthropic ───────────────────────────────────────────────────────── */
551
+
552
+ function handleClaude(type: string, ev: any): void {
553
+ if (type === 'ping') return;
554
+
555
+ if (type === 'error') {
556
+ // Shape preserved verbatim: isErrorResponseBody() in errors.ts matches on
557
+ // `type === 'error'` and on `error.message` / `error.type`, so the caller's
558
+ // existing error path recognises this without a special case.
559
+ takeError({ type: 'error', error: ev && ev.error ? ev.error : ev });
560
+ return;
561
+ }
562
+
563
+ if (type === 'message_start') {
564
+ // The full Message minus its content, which the blocks below rebuild. Cloned
565
+ // shallowly so a later message_delta writing stop_reason cannot mutate the
566
+ // caller's copy of a frame it may have kept.
567
+ message = ev && ev.message ? shallowClone(ev.message) : { type: 'message', role: 'assistant' };
568
+ if (typeof message.stop_reason === 'string') stopReason = message.stop_reason;
569
+ return;
570
+ }
571
+
572
+ if (type === 'content_block_start') {
573
+ var idx = numberOr(ev.index, -1);
574
+ if (idx < 0) {
575
+ malformedFrames++;
576
+ return;
577
+ }
578
+ // The start frame carries the block's real skeleton, including fields this
579
+ // module never touches (`citations: null` on a text block, `id`/`name` on a
580
+ // tool block, a server tool's result payload). Copying it verbatim rather
581
+ // than synthesising one is what keeps the assembled body byte equivalent to
582
+ // the buffered response.
583
+ var block = ev.content_block ? shallowClone(ev.content_block) : {};
584
+ blocks.set(idx, { block: block, json: '', sawJson: false });
585
+ invalidate();
586
+ if (block && typeof block.type === 'string' && CLAUDE_TOOL_BLOCKS[block.type]) {
587
+ var call: SseToolCall = {
588
+ index: idx,
589
+ name: typeof block.name === 'string' && block.name ? block.name : block.type,
590
+ type: block.type,
591
+ };
592
+ if (typeof block.server_name === 'string') call.serverName = block.server_name;
593
+ toolCalls.push(call);
594
+ }
595
+ return;
596
+ }
597
+
598
+ if (type === 'content_block_delta') {
599
+ var i = numberOr(ev.index, -1);
600
+ var d = ev.delta;
601
+ if (i < 0 || !d || typeof d !== 'object') {
602
+ malformedFrames++;
603
+ return;
604
+ }
605
+ var st = blocks.get(i);
606
+ if (!st) {
607
+ // A delta for a block whose start we never saw. Only reachable if a frame
608
+ // was lost; the block is created empty so the delta still lands somewhere
609
+ // and the INDEX is still occupied, which is what keeps the text join from
610
+ // collapsing two paragraphs into one.
611
+ st = { block: { type: deltaBlockType(d.type) }, json: '', sawJson: false };
612
+ blocks.set(i, st);
613
+ }
614
+ applyClaudeDelta(st, d);
615
+ invalidate();
616
+ return;
617
+ }
618
+
619
+ if (type === 'content_block_stop') {
620
+ var j = numberOr(ev.index, -1);
621
+ var s = j >= 0 ? blocks.get(j) : undefined;
622
+ if (s && s.sawJson) finishToolJson(s);
623
+ return;
624
+ }
625
+
626
+ if (type === 'message_delta') {
627
+ if (!message) message = { type: 'message', role: 'assistant' };
628
+ var delta = ev.delta;
629
+ if (delta && typeof delta === 'object') {
630
+ for (var k in delta) {
631
+ if (Object.prototype.hasOwnProperty.call(delta, k)) message[k] = delta[k];
632
+ }
633
+ if (typeof delta.stop_reason === 'string') stopReason = delta.stop_reason;
634
+ }
635
+ // Anthropic reports the final output_tokens here while the input side was
636
+ // reported on message_start, so the two MERGE. Replacing would drop the
637
+ // input counts the budget code reads back.
638
+ if (ev.usage && typeof ev.usage === 'object') {
639
+ message.usage = mergeInto(shallowClone(message.usage) || {}, ev.usage);
640
+ }
641
+ return;
642
+ }
643
+
644
+ if (type === 'message_stop') {
645
+ terminalEvent = 'message_stop';
646
+ return;
647
+ }
648
+
649
+ malformedFrames++;
650
+ }
651
+
652
+ function applyClaudeDelta(st: ClaudeBlock, d: any): void {
653
+ var t = d.type;
654
+ if (t === 'text_delta') {
655
+ st.block.text = (st.block.text || '') + str(d.text);
656
+ return;
657
+ }
658
+ if (t === 'thinking_delta') {
659
+ st.block.thinking = (st.block.thinking || '') + str(d.thinking);
660
+ return;
661
+ }
662
+ if (t === 'signature_delta') {
663
+ st.block.signature = (st.block.signature || '') + str(d.signature);
664
+ return;
665
+ }
666
+ if (t === 'input_json_delta') {
667
+ // Accumulated as TEXT and parsed once, at content_block_stop. Each fragment
668
+ // is a slice of one JSON document chosen by the provider's tokeniser, so a
669
+ // fragment on its own is routinely not valid JSON ('{"tab', 'le":"sal',
670
+ // 'es"}'), and no fragment may reach the live answer text.
671
+ st.json += str(d.partial_json);
672
+ st.sawJson = true;
673
+ return;
674
+ }
675
+ if (t === 'citations_delta') {
676
+ if (d.citation) {
677
+ if (!Array.isArray(st.block.citations)) st.block.citations = [];
678
+ st.block.citations.push(d.citation);
679
+ }
680
+ return;
681
+ }
682
+ // An unknown delta type is counted rather than guessed at: guessing is how a
683
+ // future block's payload ends up appended to the user's answer.
684
+ malformedFrames++;
685
+ }
686
+
687
+ function finishToolJson(st: ClaudeBlock): void {
688
+ if (!st.json.length) {
689
+ // A tool called with no arguments streams no fragments at all. The start
690
+ // frame's `input: {}` is already correct; overwriting it would be a change
691
+ // for its own sake.
692
+ return;
693
+ }
694
+ try {
695
+ st.block.input = JSON.parse(st.json);
696
+ } catch (e) {
697
+ // Truncated or mangled arguments. The block keeps the start frame's `input`
698
+ // and the count says so, because inventing an input would hand the caller a
699
+ // tool call that looks complete and is not.
700
+ malformedToolJson++;
701
+ }
702
+ }
703
+
704
+ function deltaBlockType(deltaType: any): string {
705
+ if (deltaType === 'thinking_delta' || deltaType === 'signature_delta') return 'thinking';
706
+ if (deltaType === 'input_json_delta') return 'tool_use';
707
+ return 'text';
708
+ }
709
+
710
+ /* ── OpenAI Responses ────────────────────────────────────────────────── */
711
+
712
+ function handleOpenAI(type: string, ev: any): void {
713
+ if (type === 'response.output_text.delta') {
714
+ putPart(ev, str(ev.delta), false);
715
+ invalidate();
716
+ return;
717
+ }
718
+
719
+ if (type === 'response.output_text.done') {
720
+ // The done frame carries the part's COMPLETE text. Taking it as authoritative
721
+ // repairs a part that lost a delta (a chunk the worker could not write, a
722
+ // capped read the client resumed from the wrong cursor) instead of rendering
723
+ // a hole nobody can see.
724
+ if (typeof ev.text === 'string') {
725
+ putPart(ev, ev.text, true);
726
+ invalidate();
727
+ }
728
+ return;
729
+ }
730
+
731
+ if (type === 'response.reasoning_summary_text.delta' || type === 'response.reasoning_text.delta') {
732
+ putReasoning(type, ev, str(ev.delta), false);
733
+ invalidate();
734
+ return;
735
+ }
736
+
737
+ if (type === 'response.reasoning_summary_text.done' || type === 'response.reasoning_text.done') {
738
+ // Same repair as output_text.done: the done frame carries the part's
739
+ // COMPLETE text, so a part that lost a delta is healed instead of leaving a
740
+ // hole in the middle of the thinking.
741
+ if (typeof ev.text === 'string') {
742
+ putReasoning(type, ev, ev.text, true);
743
+ invalidate();
744
+ }
745
+ return;
746
+ }
747
+
748
+ if (type === 'response.output_item.added') {
749
+ var item = ev.item;
750
+ if (item && typeof item.type === 'string' && OPENAI_TOOL_ITEMS[item.type]) {
751
+ toolCalls.push({
752
+ index: numberOr(ev.output_index, toolCalls.length),
753
+ // A built-in tool (web_search_call) has no name of its own, so the item
754
+ // type is the only label there is and a row can still be drawn.
755
+ name: typeof item.name === 'string' && item.name ? item.name : item.type,
756
+ type: item.type,
757
+ });
758
+ }
759
+ return;
760
+ }
761
+
762
+ if (type === 'response.completed' || type === 'response.incomplete' || type === 'response.failed') {
763
+ terminalEvent = type;
764
+ // THE TERMINAL EVENT CARRIES THE COMPLETE RESPONSE OBJECT, so for OpenAI
765
+ // there is nothing to rebuild: this IS the body a buffered call would have
766
+ // returned, kept verbatim. Everything accumulated above exists only to have
767
+ // something to render before this frame arrives.
768
+ if (ev.response && typeof ev.response === 'object') {
769
+ response = ev.response;
770
+ var st = response.status;
771
+ if (st === 'incomplete') {
772
+ var reason = response.incomplete_details && response.incomplete_details.reason;
773
+ stopReason = typeof reason === 'string' && reason ? reason : 'incomplete';
774
+ } else if (typeof st === 'string' && st) {
775
+ stopReason = st;
776
+ }
777
+ if (response.error && (response.error.message || response.error.code)) {
778
+ errored = true;
779
+ error = response;
780
+ }
781
+ }
782
+ if (type === 'response.failed') errored = true;
783
+ return;
784
+ }
785
+
786
+ if (type === 'response.error' || type === 'error') {
787
+ takeError(ev && ev.error ? ev : { type: 'error', error: ev });
788
+ return;
789
+ }
790
+
791
+ // Every other Responses event (response.created, .in_progress,
792
+ // .output_item.done, .content_part.*, .function_call_arguments.*,
793
+ // .mcp_call.*, .reasoning_summary_part.*, ...) is real and expected. It is
794
+ // simply not needed here, and counting it as malformed would make a healthy
795
+ // turn look broken.
796
+ }
797
+
798
+ /**
799
+ * OpenAI reasoning text. RENDER ONLY: it feeds `thinkingText` and nothing else.
800
+ * It cannot reach `text` (a different accumulator) and it cannot reach the final
801
+ * body (which is the terminal Response verbatim), so populating it can only add
802
+ * a "thinking..." affordance, never alter the answer or the stored turn.
803
+ *
804
+ * Two event families are read because the Responses API has two: the summary
805
+ * (`response.reasoning_summary_text.*`, indexed by summary_index) that
806
+ * summarising models emit, and raw reasoning (`response.reasoning_text.*`,
807
+ * indexed by content_index) that models exposing their reasoning emit. A given
808
+ * response emits one family, not both. The key carries the family anyway, so
809
+ * that if one ever did emit both, a summary part and a reasoning part sharing an
810
+ * index could not overwrite each other and silently drop half the thinking.
811
+ */
812
+ function putReasoning(type: string, ev: any, text: string, replace: boolean): void {
813
+ var summary = type.indexOf('response.reasoning_summary_text.') === 0;
814
+ var oi = numberOr(ev.output_index, 0);
815
+ var idx = numberOr(summary ? ev.summary_index : ev.content_index, 0);
816
+ var key = oi + ':' + (summary ? 's' : 'r') + ':' + idx;
817
+ var r = reasoning.get(key);
818
+ if (!r) {
819
+ r = { oi: oi, idx: idx, kind: summary ? 0 : 1, text: '' };
820
+ reasoning.set(key, r);
821
+ }
822
+ r.text = replace ? text : r.text + text;
823
+ }
824
+
825
+ function putPart(ev: any, text: string, replace: boolean): void {
826
+ var oi = numberOr(ev.output_index, 0);
827
+ var ci = numberOr(ev.content_index, 0);
828
+ var key = oi + ':' + ci;
829
+ var p = parts.get(key);
830
+ if (!p) {
831
+ p = { oi: oi, ci: ci, text: '' };
832
+ parts.set(key, p);
833
+ }
834
+ p.text = replace ? text : p.text + text;
835
+ }
836
+
837
+ /* ── outputs ─────────────────────────────────────────────────────────── */
838
+
839
+ function invalidate(): void {
840
+ textCache = null;
841
+ thinkingCache = null;
842
+ }
843
+
844
+ function claudeTextBlocks(): any[] {
845
+ return orderedBlocks().filter(function (b) {
846
+ return b && b.type === 'text';
847
+ });
848
+ }
849
+
850
+ function orderedBlocks(): any[] {
851
+ var idx: number[] = [];
852
+ blocks.forEach(function (_v, k) {
853
+ idx.push(k);
854
+ });
855
+ idx.sort(function (a, b) {
856
+ return a - b;
857
+ });
858
+ var out: any[] = [];
859
+ for (var i = 0; i < idx.length; i++) out.push(blocks.get(idx[i])!.block);
860
+ return out;
861
+ }
862
+
863
+ function orderedParts(): { oi: number; ci: number; text: string }[] {
864
+ var out: { oi: number; ci: number; text: string }[] = [];
865
+ parts.forEach(function (p) {
866
+ out.push(p);
867
+ });
868
+ out.sort(function (a, b) {
869
+ return a.oi !== b.oi ? a.oi - b.oi : a.ci - b.ci;
870
+ });
871
+ return out;
872
+ }
873
+
874
+ function currentText(): string {
875
+ if (textCache !== null) return textCache;
876
+ var out: string;
877
+ if (provider === 'openai') {
878
+ // Mirrors extractOpenAIText: one join unit per output_text PART, '\n'
879
+ // between them. Untrimmed, because a render feed must not have its leading
880
+ // newline removed and then handed back when the next delta lands; the
881
+ // settled answer is trimmed by session.ts exactly as a buffered one is.
882
+ out = orderedParts()
883
+ .map(function (p) {
884
+ return p.text;
885
+ })
886
+ .join('\n');
887
+ } else {
888
+ // Mirrors extractClaudeText EXACTLY, which is the whole point: text blocks in
889
+ // index order, joined with '\n'. A tool block between two text blocks is not
890
+ // a separator to be invented later, it is why the separator exists.
891
+ out = claudeTextBlocks()
892
+ .map(function (b) {
893
+ return b.text || '';
894
+ })
895
+ .join('\n');
896
+ }
897
+ textCache = out;
898
+ return out;
899
+ }
900
+
901
+ function currentThinking(): string {
902
+ if (thinkingCache !== null) return thinkingCache;
903
+ var out: string;
904
+ if (provider === 'openai') {
905
+ // Ordered by (output item, index within it), with the family only ever
906
+ // breaking a tie, so the join is a pure function of the events and does not
907
+ // depend on which delta happened to arrive first.
908
+ var rs: { oi: number; idx: number; kind: number; text: string }[] = [];
909
+ reasoning.forEach(function (r) {
910
+ rs.push(r);
911
+ });
912
+ rs.sort(function (a, b) {
913
+ if (a.oi !== b.oi) return a.oi - b.oi;
914
+ if (a.idx !== b.idx) return a.idx - b.idx;
915
+ return a.kind - b.kind;
916
+ });
917
+ out = rs
918
+ .map(function (r) {
919
+ return r.text;
920
+ })
921
+ .join('\n');
922
+ } else {
923
+ out = orderedBlocks()
924
+ .filter(function (b) {
925
+ return b && b.type === 'thinking';
926
+ })
927
+ .map(function (b) {
928
+ return b.thinking || '';
929
+ })
930
+ .join('\n');
931
+ }
932
+ thinkingCache = out;
933
+ return out;
934
+ }
935
+
936
+ function buildBody(): any {
937
+ if (provider === 'openai') {
938
+ // Verbatim, terminal-event-only. Nothing is assembled from the deltas: the
939
+ // Response object is complete by construction, and rebuilding one from
940
+ // fragments could only ever produce a near-miss of it.
941
+ //
942
+ // FALLS THROUGH when there is no terminal Response. An OpenAI stream that
943
+ // dies on an `error` frame never sends one, so this used to `return response`
944
+ // (null) before the error fallback below could run, and the destination's own
945
+ // explanation of what went wrong, already parsed and sitting in `error`, was
946
+ // unreachable from the result. The caller then had an empty turn and no
947
+ // reason for it. Returning here only when a Response actually arrived is the
948
+ // whole fix.
949
+ if (response) return response;
950
+ } else if (blocks.size || message) {
951
+ var base = message ? shallowClone(message) : { type: 'message', role: 'assistant' };
952
+ base.content = orderedBlocks();
953
+ return base;
954
+ }
955
+ // Nothing was assembled. If the stream died on an error frame, the error IS
956
+ // what a buffered call would have returned, and takeError() already stored it
957
+ // in the shape isErrorResponseBody() recognises (`type: 'error'` with the
958
+ // payload under `error`), so one code path in the caller reads a streamed
959
+ // error and a buffered one.
960
+ if (errored && error) return error;
961
+ // No frames at all: the bytes were never SSE, so they ARE the body. Handed
962
+ // over uninterpreted; see the header.
963
+ return unframedBody();
964
+ }
965
+
966
+ /**
967
+ * The unframed bytes as a body, or null when they cannot be one.
968
+ *
969
+ * A non-object result (a bare `12`, `"ok"`, `null`) is refused for the same
970
+ * reason dispatch() refuses one inside a frame: it is not a response body, and
971
+ * handing it back as one would put a caller's `body.error` lookup on a number.
972
+ * Whatever was refused is still readable in full through `unframedText`.
973
+ */
974
+ function unframedBody(): any {
975
+ if (!isUnframed()) return null;
976
+ if (rawParsed) return rawBody;
977
+ rawParsed = true;
978
+ try {
979
+ var v = JSON.parse(raw);
980
+ rawBody = v && typeof v === 'object' ? v : null;
981
+ } catch (e) {
982
+ // Not JSON at all: a gateway's HTML error page, a plain-text 502. There is
983
+ // no body to give, and inventing an envelope for it would be exactly the
984
+ // vendor guessing this module refuses. `unframedText` carries the bytes.
985
+ rawBody = null;
986
+ }
987
+ return rawBody;
988
+ }
989
+
990
+ function snapshot(): SseSnapshot {
991
+ return {
992
+ provider: provider,
993
+ text: currentText(),
994
+ thinkingText: currentThinking(),
995
+ toolCalls: toolCalls.slice(),
996
+ toolNames: toolCalls.map(function (t) {
997
+ return t.name;
998
+ }),
999
+ stopReason: stopReason,
1000
+ complete: terminalEvent !== null,
1001
+ // A terminal event that ENDED the answer rather than KILLED it. The
1002
+ // `errored` term covers all three ways a stream dies with a terminal event
1003
+ // on it: an Anthropic or OpenAI `error` frame (takeError sets both), a
1004
+ // response.failed, and a response.completed/incomplete whose Response
1005
+ // object carries an error payload. See the field's own doc for the loss
1006
+ // this separation prevents.
1007
+ answerComplete: terminalEvent !== null && terminalEvent !== 'error' && !errored,
1008
+ terminalEvent: terminalEvent,
1009
+ errored: errored,
1010
+ error: error,
1011
+ malformedFrames: malformedFrames,
1012
+ malformedToolJson: malformedToolJson,
1013
+ unframed: isUnframed(),
1014
+ unframedText: isUnframed() ? raw : null,
1015
+ lastSeq: lastSeq,
1016
+ };
1017
+ }
1018
+
1019
+ return {
1020
+ feed: feed,
1021
+ feedChunks: feedChunks,
1022
+ end: end,
1023
+ snapshot: snapshot,
1024
+ finalBody: buildBody,
1025
+ };
1026
+ }
1027
+
1028
+ /* ── small helpers ───────────────────────────────────────────────────────── */
1029
+
1030
+ function str(v: any): string {
1031
+ return typeof v === 'string' ? v : '';
1032
+ }
1033
+
1034
+ function numberOr(v: any, fallback: number): number {
1035
+ return typeof v === 'number' && isFinite(v) ? v : fallback;
1036
+ }
1037
+
1038
+ function shallowClone(o: any): any {
1039
+ if (!o || typeof o !== 'object') return o;
1040
+ var out: any = Array.isArray(o) ? o.slice() : {};
1041
+ if (!Array.isArray(o)) {
1042
+ for (var k in o) {
1043
+ if (Object.prototype.hasOwnProperty.call(o, k)) out[k] = o[k];
1044
+ }
1045
+ }
1046
+ return out;
1047
+ }
1048
+
1049
+ function mergeInto(target: any, src: any): any {
1050
+ for (var k in src) {
1051
+ if (Object.prototype.hasOwnProperty.call(src, k)) target[k] = src[k];
1052
+ }
1053
+ return target;
1054
+ }