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.
- package/README.md +5 -0
- package/bunnyquery.css +53 -2
- package/bunnyquery.js +1822 -177
- package/dist/engine.cjs +1746 -114
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +1271 -6
- package/dist/engine.d.ts +1271 -6
- package/dist/engine.mjs +1709 -115
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/config.ts +243 -0
- package/src/engine/errors.ts +87 -1
- package/src/engine/history.ts +113 -2
- package/src/engine/host.ts +85 -0
- package/src/engine/index.ts +108 -2
- package/src/engine/project_settings.ts +303 -0
- package/src/engine/prompts/chat_system_prompt.ts +15 -3
- package/src/engine/requests.ts +130 -1
- package/src/engine/session.ts +1553 -29
- package/src/engine/sse.ts +1054 -0
- package/src/widget.css +21 -2
- package/styles/chat.css +32 -0
|
@@ -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
|
+
}
|