@askalf/dario 6.0.53 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/cli.js +19 -1
- package/dist/codex-backend.d.ts +16 -1
- package/dist/codex-backend.js +54 -6
- package/dist/midstream.d.ts +321 -0
- package/dist/midstream.js +872 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +130 -4
- package/docs/midstream-continuation.md +144 -0
- package/package.json +1 -1
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mid-stream continuation (v6.1) — the answer does not stop when the plan does.
|
|
3
|
+
*
|
|
4
|
+
* Until now a streamed `/v1/messages` (or `/v1/chat/completions`) answer that
|
|
5
|
+
* died part-way through — an upstream socket reset, an in-band
|
|
6
|
+
* `overloaded_error`, a codex `response.failed` — ended with `res.end()` and
|
|
7
|
+
* the client got a truncated stream: no `message_stop`, no `[DONE]`, an SDK
|
|
8
|
+
* that throws "stream ended without producing a Message", and every word
|
|
9
|
+
* already on screen wasted. Once bytes were on the wire the request was
|
|
10
|
+
* treated as too late to hand to anyone else.
|
|
11
|
+
*
|
|
12
|
+
* This module finishes the SAME client stream from the other subscription
|
|
13
|
+
* instead. It sits between the request handler and the client socket:
|
|
14
|
+
*
|
|
15
|
+
* 1. every frame written to the client passes through `write()`, which
|
|
16
|
+
* tracks what the client has already seen (message_start, the open
|
|
17
|
+
* content block, the text so far) and WITHHOLDS a terminal error frame
|
|
18
|
+
* rather than forwarding it;
|
|
19
|
+
* 2. `finish()` replaces the site's `res.end()`. A clean stream ends as
|
|
20
|
+
* before. A stream that died with content on the wire re-issues the
|
|
21
|
+
* request through dario's own front door (a loopback POST — so the pool,
|
|
22
|
+
* the codex translator, cch and every other rule apply to the resume
|
|
23
|
+
* exactly as to any client request) at the OTHER provider, with the
|
|
24
|
+
* partial answer appended as the assistant turn and a resume notice as
|
|
25
|
+
* the user turn;
|
|
26
|
+
* 3. the resume stream is spliced onto the client's still-open block: its
|
|
27
|
+
* message_start and thinking blocks are dropped, its first text block
|
|
28
|
+
* continues the open index, anything after that is renumbered, and it
|
|
29
|
+
* closes the message with its own message_delta / message_stop.
|
|
30
|
+
*
|
|
31
|
+
* Two things the spike (2026-09-11, prod 6.0.51, real Opus 5 + real ChatGPT
|
|
32
|
+
* Plus) settled that are easy to get wrong again:
|
|
33
|
+
*
|
|
34
|
+
* - NO assistant prefill. Claude 4.6+/5 answers a trailing assistant turn
|
|
35
|
+
* with a 400, and the Responses API never had the concept. The resume is
|
|
36
|
+
* instruction-driven on both providers, which works — zero restarts, zero
|
|
37
|
+
* preamble, zero repetition across ten real runs.
|
|
38
|
+
* - The seam is a WHITESPACE problem, not a content problem. Told merely to
|
|
39
|
+
* "continue", Claude-as-continuer dropped the boundary space 2/3 times
|
|
40
|
+
* (`replies<CUT>with`, a 3-vs-4-space indent). So the notice asks the model
|
|
41
|
+
* to begin by repeating the last ~40 characters verbatim, and `findAnchor`
|
|
42
|
+
* trims that repeat with a whitespace-normalized match. The model renders
|
|
43
|
+
* the seam inside its own token stream; we only cut. Matched exactly 5/5.
|
|
44
|
+
*
|
|
45
|
+
* Out of scope here, on purpose: a cut inside a tool_use block (the partial
|
|
46
|
+
* JSON is not resumable), non-streaming requests (nothing is on the wire yet;
|
|
47
|
+
* the existing pre-byte failover covers them), and the api-key OpenAI backend.
|
|
48
|
+
* A stream that cannot be continued ends exactly as it did before this module.
|
|
49
|
+
*/
|
|
50
|
+
/** Client-visible marker that a loopback request is a continuation, so the handler never nests one. */
|
|
51
|
+
export const CONTINUATION_HEADER = 'x-dario-continuation';
|
|
52
|
+
/** Characters of the partial the model is asked to repeat verbatim (the seam anchor). */
|
|
53
|
+
export const ANCHOR_CHARS = 40;
|
|
54
|
+
/** Upper bound on continuation text held back while looking for the anchor. */
|
|
55
|
+
const HOLD_CHARS = 240;
|
|
56
|
+
/**
|
|
57
|
+
* Splits a byte/text stream into complete SSE frames. A trailing partial frame
|
|
58
|
+
* stays buffered until its blank line arrives. Frames are returned with their
|
|
59
|
+
* original bytes, so forwarding `raw` is byte-identical to the input.
|
|
60
|
+
*/
|
|
61
|
+
export class SseFrameSplitter {
|
|
62
|
+
buf = '';
|
|
63
|
+
decoder = new TextDecoder();
|
|
64
|
+
feed(chunk) {
|
|
65
|
+
this.buf += typeof chunk === 'string' ? chunk : this.decoder.decode(chunk, { stream: true });
|
|
66
|
+
const out = [];
|
|
67
|
+
let idx;
|
|
68
|
+
while ((idx = this.buf.indexOf('\n\n')) >= 0) {
|
|
69
|
+
const raw = this.buf.slice(0, idx + 2);
|
|
70
|
+
this.buf = this.buf.slice(idx + 2);
|
|
71
|
+
out.push(parseFrame(raw));
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** Whatever is buffered and not yet a complete frame. */
|
|
76
|
+
flush() {
|
|
77
|
+
const rest = this.buf + this.decoder.decode();
|
|
78
|
+
this.buf = '';
|
|
79
|
+
return rest;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function parseFrame(raw) {
|
|
83
|
+
let event = '';
|
|
84
|
+
let dataText = null;
|
|
85
|
+
let comment = true;
|
|
86
|
+
for (const line of raw.split('\n')) {
|
|
87
|
+
if (line === '')
|
|
88
|
+
continue;
|
|
89
|
+
if (line.startsWith(':'))
|
|
90
|
+
continue;
|
|
91
|
+
comment = false;
|
|
92
|
+
if (line.startsWith('event:'))
|
|
93
|
+
event = line.slice(6).trim();
|
|
94
|
+
else if (line.startsWith('data:'))
|
|
95
|
+
dataText = (dataText === null ? '' : dataText + '\n') + line.slice(5).trim();
|
|
96
|
+
}
|
|
97
|
+
let data = null;
|
|
98
|
+
if (dataText !== null && dataText !== '[DONE]') {
|
|
99
|
+
try {
|
|
100
|
+
const v = JSON.parse(dataText);
|
|
101
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v))
|
|
102
|
+
data = v;
|
|
103
|
+
}
|
|
104
|
+
catch { /* non-JSON data: forwarded verbatim, never interpreted */ }
|
|
105
|
+
}
|
|
106
|
+
if (!event && data && typeof data.type === 'string')
|
|
107
|
+
event = data.type;
|
|
108
|
+
return { raw, event, data, dataText, comment };
|
|
109
|
+
}
|
|
110
|
+
export function formatFrame(event, data) {
|
|
111
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The client-side state a continuation has to pick up from. Both shapes are
|
|
115
|
+
* tracked by ONE class so the guard has a single view: `blocks` carries
|
|
116
|
+
* Anthropic content blocks; on the OpenAI shape there is exactly one implicit
|
|
117
|
+
* text block (index 0) that opens on the first content delta.
|
|
118
|
+
*/
|
|
119
|
+
export class ClientStreamState {
|
|
120
|
+
shape;
|
|
121
|
+
started = false; // message_start seen / first chunk with choices seen
|
|
122
|
+
finished = false; // message_stop / [DONE] / finish_reason seen
|
|
123
|
+
blocks = [];
|
|
124
|
+
/**
|
|
125
|
+
* Frames the guard withheld instead of forwarding, in order: a terminal
|
|
126
|
+
* error, and — once the site has flagged the upstream as failed — the
|
|
127
|
+
* closing frames a translator emits for a failed turn. Released verbatim
|
|
128
|
+
* if no continuation happens, so the client sees exactly what it would have.
|
|
129
|
+
*/
|
|
130
|
+
withheld = [];
|
|
131
|
+
/**
|
|
132
|
+
* Set by the site when it KNOWS the upstream turn failed even though the
|
|
133
|
+
* translator will close it politely (the codex Anthropic path answers
|
|
134
|
+
* `response.failed` with message_delta + message_stop). The closing frames
|
|
135
|
+
* are then withheld so the stream reads as unfinished, i.e. continuable.
|
|
136
|
+
*/
|
|
137
|
+
upstreamFailed = false;
|
|
138
|
+
/** True once anything non-continuable was seen (tool_use in progress, tool_calls). */
|
|
139
|
+
toolInProgress = false;
|
|
140
|
+
forwardedFrames = 0;
|
|
141
|
+
/** message_start's message.model — kept so a continuation can name what the client believes it is talking to. */
|
|
142
|
+
model = null;
|
|
143
|
+
constructor(shape) {
|
|
144
|
+
this.shape = shape;
|
|
145
|
+
}
|
|
146
|
+
get openIdx() { return this.blocks.findIndex((b) => b.open); }
|
|
147
|
+
get openType() { const i = this.openIdx; return i < 0 ? null : this.blocks[i].type; }
|
|
148
|
+
/** Every text emitted so far, blocks concatenated in order. */
|
|
149
|
+
get textSoFar() { return this.blocks.filter((b) => b.type === 'text').map((b) => b.text).join(''); }
|
|
150
|
+
/** Text of the open text block only — what the seam anchor is cut from. */
|
|
151
|
+
get openText() { const i = this.openIdx; return i >= 0 && this.blocks[i].type === 'text' ? this.blocks[i].text : ''; }
|
|
152
|
+
/**
|
|
153
|
+
* Whether a stream that stopped HERE can be continued: bytes are on the
|
|
154
|
+
* wire, the message is not finished, and nothing non-resumable is open.
|
|
155
|
+
* An open tool_use block or an OpenAI tool call in flight is a definite no —
|
|
156
|
+
* half a JSON argument object cannot be handed to another model.
|
|
157
|
+
*/
|
|
158
|
+
get continuable() {
|
|
159
|
+
if (!this.started || this.finished || this.toolInProgress)
|
|
160
|
+
return false;
|
|
161
|
+
const t = this.openType;
|
|
162
|
+
return t === null || t === 'text' || t === 'thinking' || t === 'redacted_thinking';
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Observe one client-bound frame. Returns false when the frame is a terminal
|
|
166
|
+
* error the guard should withhold (recorded in `withheld`), true to forward.
|
|
167
|
+
*/
|
|
168
|
+
observe(f) {
|
|
169
|
+
if (f.comment || f.dataText === null)
|
|
170
|
+
return true;
|
|
171
|
+
if (this.shape === 'anthropic')
|
|
172
|
+
return this.observeAnthropic(f);
|
|
173
|
+
return this.observeOpenAI(f);
|
|
174
|
+
}
|
|
175
|
+
observeAnthropic(f) {
|
|
176
|
+
const d = f.data;
|
|
177
|
+
if (!d)
|
|
178
|
+
return true;
|
|
179
|
+
switch (d.type) {
|
|
180
|
+
case 'message_start': {
|
|
181
|
+
this.started = true;
|
|
182
|
+
const m = d.message;
|
|
183
|
+
if (typeof m?.model === 'string')
|
|
184
|
+
this.model = m.model;
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
case 'content_block_start': {
|
|
188
|
+
const idx = typeof d.index === 'number' ? d.index : this.blocks.length;
|
|
189
|
+
const cb = d.content_block;
|
|
190
|
+
const type = cb?.type ?? 'text';
|
|
191
|
+
this.blocks[idx] = { type, open: true, text: cb?.text ?? '' };
|
|
192
|
+
if (type === 'tool_use' || type === 'server_tool_use')
|
|
193
|
+
this.toolInProgress = true;
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
case 'content_block_delta': {
|
|
197
|
+
const idx = typeof d.index === 'number' ? d.index : -1;
|
|
198
|
+
const b = this.blocks[idx];
|
|
199
|
+
const delta = d.delta;
|
|
200
|
+
if (b && delta?.type === 'text_delta' && typeof delta.text === 'string')
|
|
201
|
+
b.text += delta.text;
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
case 'content_block_stop': {
|
|
205
|
+
const idx = typeof d.index === 'number' ? d.index : -1;
|
|
206
|
+
const b = this.blocks[idx];
|
|
207
|
+
if (b) {
|
|
208
|
+
b.open = false;
|
|
209
|
+
// A tool_use block that CLOSED is a complete call the client can act
|
|
210
|
+
// on; the message is no longer text-resumable though — the model's
|
|
211
|
+
// next move after a tool call is the tool result, not more prose.
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
case 'message_delta':
|
|
216
|
+
if (this.upstreamFailed) {
|
|
217
|
+
this.withheld.push(f);
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
case 'message_stop':
|
|
222
|
+
if (this.upstreamFailed) {
|
|
223
|
+
this.withheld.push(f);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
this.finished = true;
|
|
227
|
+
return true;
|
|
228
|
+
case 'error':
|
|
229
|
+
if (!this.started)
|
|
230
|
+
return true; // pre-byte errors are the existing failover paths' business
|
|
231
|
+
this.withheld.push(f);
|
|
232
|
+
return false;
|
|
233
|
+
default:
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
observeOpenAI(f) {
|
|
238
|
+
if (f.dataText === '[DONE]') {
|
|
239
|
+
if (this.upstreamFailed && this.started) {
|
|
240
|
+
this.withheld.push(f);
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
this.finished = true;
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
const d = f.data;
|
|
247
|
+
if (!d)
|
|
248
|
+
return true;
|
|
249
|
+
if (d.error && this.started) {
|
|
250
|
+
this.withheld.push(f);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
const choices = d.choices;
|
|
254
|
+
if (!Array.isArray(choices))
|
|
255
|
+
return true;
|
|
256
|
+
this.started = true;
|
|
257
|
+
if (typeof d.model === 'string' && !this.model)
|
|
258
|
+
this.model = d.model;
|
|
259
|
+
const c = choices[0];
|
|
260
|
+
if (!c)
|
|
261
|
+
return true;
|
|
262
|
+
if (c.delta?.tool_calls)
|
|
263
|
+
this.toolInProgress = true;
|
|
264
|
+
if (typeof c.delta?.content === 'string' && c.delta.content.length > 0) {
|
|
265
|
+
if (this.blocks.length === 0)
|
|
266
|
+
this.blocks.push({ type: 'text', open: true, text: '' });
|
|
267
|
+
this.blocks[0].text += c.delta.content;
|
|
268
|
+
}
|
|
269
|
+
if (c.finish_reason) {
|
|
270
|
+
if (this.upstreamFailed) {
|
|
271
|
+
this.withheld.push(f);
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
this.finished = true;
|
|
275
|
+
}
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// The seam: anchor + trim
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
/**
|
|
283
|
+
* The tail of the partial the model is told to repeat. Starts at a
|
|
284
|
+
* non-whitespace character: the API strips a reply's leading whitespace, so an
|
|
285
|
+
* anchor beginning with a space could never be matched exactly.
|
|
286
|
+
*/
|
|
287
|
+
export function anchorOf(partial) {
|
|
288
|
+
const a = partial.slice(-ANCHOR_CHARS);
|
|
289
|
+
return a.slice(a.length - a.trimStart().length);
|
|
290
|
+
}
|
|
291
|
+
/** Collapse whitespace runs to one space and fold curly quotes, keeping a map back to raw offsets. */
|
|
292
|
+
function normalize(s) {
|
|
293
|
+
const map = [];
|
|
294
|
+
let out = '';
|
|
295
|
+
let ws = false;
|
|
296
|
+
for (let i = 0; i < s.length; i++) {
|
|
297
|
+
let c = s[i];
|
|
298
|
+
if (/\s/.test(c)) {
|
|
299
|
+
if (ws)
|
|
300
|
+
continue;
|
|
301
|
+
ws = true;
|
|
302
|
+
c = ' ';
|
|
303
|
+
}
|
|
304
|
+
else
|
|
305
|
+
ws = false;
|
|
306
|
+
if (c === '‘' || c === '’')
|
|
307
|
+
c = "'";
|
|
308
|
+
if (c === '“' || c === '”')
|
|
309
|
+
c = '"';
|
|
310
|
+
out += c;
|
|
311
|
+
map.push(i);
|
|
312
|
+
}
|
|
313
|
+
return { out, map };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Locate the repeated anchor at the head of the continuation and return the
|
|
317
|
+
* raw offset just past it, or null when the model did not repeat it. Tries the
|
|
318
|
+
* whole anchor first, then shorter tails, tolerating whitespace and quote
|
|
319
|
+
* differences; the match must sit at (or within a few characters of) the
|
|
320
|
+
* start, so a genuine later recurrence of the phrase is never mistaken for it.
|
|
321
|
+
*/
|
|
322
|
+
export function findAnchor(partial, head) {
|
|
323
|
+
const full = anchorOf(partial);
|
|
324
|
+
if (full.length < 8)
|
|
325
|
+
return null;
|
|
326
|
+
const H = normalize(head.slice(0, HOLD_CHARS * 3));
|
|
327
|
+
for (const len of [full.length, 32, 24, 16, 12]) {
|
|
328
|
+
if (len > full.length)
|
|
329
|
+
continue;
|
|
330
|
+
const tail = full.slice(-len);
|
|
331
|
+
const a = normalize(tail).out.trim();
|
|
332
|
+
if (a.length < 8)
|
|
333
|
+
continue;
|
|
334
|
+
const at = H.out.indexOf(a);
|
|
335
|
+
if (at < 0 || at > 8)
|
|
336
|
+
continue;
|
|
337
|
+
const endNorm = at + a.length;
|
|
338
|
+
const cut = endNorm < H.map.length ? H.map[endNorm] : head.length;
|
|
339
|
+
return { cut, exact: head.slice(0, cut) === tail };
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
/** Longest suffix of `partial` that the continuation starts with (exact bytes), for the no-anchor fallback. */
|
|
344
|
+
export function tailOverlap(partial, head, min = 6) {
|
|
345
|
+
const max = Math.min(partial.length, head.length);
|
|
346
|
+
for (let k = max; k >= min; k--)
|
|
347
|
+
if (partial.endsWith(head.slice(0, k)))
|
|
348
|
+
return k;
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
/** True when the partial has an odd number of ``` fences, i.e. the cut is inside a code block. */
|
|
352
|
+
export function insideCodeFence(partial) {
|
|
353
|
+
return ((partial.match(/```/g) ?? []).length % 2) === 1;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The one seam defect the spike saw from a real model: Claude, resuming
|
|
357
|
+
* prose that was cut mid-sentence, once started its continuation with a
|
|
358
|
+
* paragraph break (`and<CUT>\n\nhere is where`). A sentence does not contain
|
|
359
|
+
* a paragraph break, so when the partial ends mid-sentence and the
|
|
360
|
+
* continuation opens with newlines outside a code fence, the break becomes
|
|
361
|
+
* one space. Inside a fence a newline is content and is left alone.
|
|
362
|
+
*/
|
|
363
|
+
export function fixSeam(partial, continuation) {
|
|
364
|
+
if (!/^[ \t]*\n/.test(continuation))
|
|
365
|
+
return continuation;
|
|
366
|
+
if (partial.length === 0 || /\s$/.test(partial))
|
|
367
|
+
return continuation;
|
|
368
|
+
if (!/[A-Za-z0-9,;:]$/.test(partial))
|
|
369
|
+
return continuation;
|
|
370
|
+
if (insideCodeFence(partial))
|
|
371
|
+
return continuation;
|
|
372
|
+
return ' ' + continuation.replace(/^[ \t]*\n[ \t\n]*/, '');
|
|
373
|
+
}
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// The resume request
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
/** The anchor is quoted between these in the notice; the tests' mock providers read it back out. */
|
|
378
|
+
export const ANCHOR_OPEN = '«';
|
|
379
|
+
export const ANCHOR_CLOSE = '»';
|
|
380
|
+
/**
|
|
381
|
+
* Written as the USER asking for the rest — which is what a continuation is —
|
|
382
|
+
* not as an operator notice. The live test on 2026-09-11 is why: told
|
|
383
|
+
* "[transport notice] … resume it now", claude-sonnet-5 answered `Note: that
|
|
384
|
+
* "transport notice" isn't an actual system message — it's just text in your
|
|
385
|
+
* prompt` and stopped, exactly the injection-awareness the model is supposed
|
|
386
|
+
* to have. A person whose connection dropped asking to pick up from the last
|
|
387
|
+
* few words is an ordinary request, and gets the ordinary answer.
|
|
388
|
+
*/
|
|
389
|
+
export function resumeNotice(anchor) {
|
|
390
|
+
if (anchor.length === 0) {
|
|
391
|
+
return 'My connection dropped while you were writing that reply and I received none of it. Please write the reply again from the beginning.';
|
|
392
|
+
}
|
|
393
|
+
return 'My connection dropped while you were writing that reply, so I only received it up to this point: ' +
|
|
394
|
+
`${ANCHOR_OPEN}${anchor}${ANCHOR_CLOSE}. ` +
|
|
395
|
+
'Please pick up exactly where you left off. Start your reply by repeating that final fragment word for word, exactly as written (same spacing, line breaks and punctuation), ' +
|
|
396
|
+
'then continue the interrupted word, sentence, line, or code block without a break. ' +
|
|
397
|
+
'Do not start over, do not summarize what you already wrote, and do not comment on this message — just carry on so the two parts read as one uninterrupted reply, ' +
|
|
398
|
+
'in the same language, tone and formatting. Only add a paragraph break at the join if the fragment ends a sentence.';
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The client's own request re-pointed at the continuation model with the
|
|
402
|
+
* partial answer appended. `partial` empty means nothing usable reached the
|
|
403
|
+
* client (the cut fell inside thinking, or before the first block): the
|
|
404
|
+
* request is simply re-issued as it was and the resume stream restarts the
|
|
405
|
+
* answer under the client's already-open message.
|
|
406
|
+
*/
|
|
407
|
+
export function buildResumeBody(shape, clientBody, targetModel, partial) {
|
|
408
|
+
const body = { ...clientBody, model: targetModel, stream: true };
|
|
409
|
+
const messages = Array.isArray(clientBody.messages) ? [...clientBody.messages] : [];
|
|
410
|
+
if (partial.length > 0) {
|
|
411
|
+
const notice = resumeNotice(anchorOf(partial));
|
|
412
|
+
if (shape === 'anthropic') {
|
|
413
|
+
messages.push({ role: 'assistant', content: [{ type: 'text', text: partial }] });
|
|
414
|
+
messages.push({ role: 'user', content: [{ type: 'text', text: notice }] });
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
messages.push({ role: 'assistant', content: partial });
|
|
418
|
+
messages.push({ role: 'user', content: notice });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
body.messages = messages;
|
|
422
|
+
// A resume never wants a forced tool call or a pinned seat: the first would
|
|
423
|
+
// make the model call a tool instead of finishing its sentence, the second
|
|
424
|
+
// is the very seat that just failed.
|
|
425
|
+
delete body.tool_choice;
|
|
426
|
+
return body;
|
|
427
|
+
}
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Splicing the resume stream onto the client stream
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
/**
|
|
432
|
+
* Turns the resume stream's frames into client-bound frames that continue the
|
|
433
|
+
* message the client already has. One instance per continuation.
|
|
434
|
+
*/
|
|
435
|
+
export class Splicer {
|
|
436
|
+
shape;
|
|
437
|
+
partial;
|
|
438
|
+
idxMap = new Map();
|
|
439
|
+
nextIdx;
|
|
440
|
+
clientOpenIdx;
|
|
441
|
+
clientOpenType;
|
|
442
|
+
originalClosed = false; // the block the client had open when the primary died
|
|
443
|
+
continuingClosed = false; // the block the resume's first text block writes into
|
|
444
|
+
firstTextMapped = false;
|
|
445
|
+
continuingIdx = -1; // client index the resume's first text block writes into
|
|
446
|
+
hold = '';
|
|
447
|
+
holding;
|
|
448
|
+
stopReasonSeen = false; // anthropic: message_delta seen; openai: finish_reason seen
|
|
449
|
+
doneSeen = false; // openai: [DONE] seen
|
|
450
|
+
/**
|
|
451
|
+
* Whether the resume stream delivered its OWN wire terminal — `message_stop`
|
|
452
|
+
* on the Anthropic shape, `[DONE]` on the OpenAI shape. Only then has the
|
|
453
|
+
* client been handed a finished message. A resume body that ends without
|
|
454
|
+
* one is a second truncation, and the guard leaves the client stream
|
|
455
|
+
* unfinished rather than closing it as if the answer were complete.
|
|
456
|
+
*/
|
|
457
|
+
terminalSeen = false;
|
|
458
|
+
/** Diagnostics for the log line. */
|
|
459
|
+
stats = { anchor: 'n/a', dropped: 0, emitted: 0 };
|
|
460
|
+
constructor(shape, state, partial) {
|
|
461
|
+
this.shape = shape;
|
|
462
|
+
this.partial = partial;
|
|
463
|
+
this.clientOpenIdx = state.openIdx;
|
|
464
|
+
this.clientOpenType = state.openType;
|
|
465
|
+
this.nextIdx = state.blocks.length;
|
|
466
|
+
this.holding = partial.length > 0;
|
|
467
|
+
}
|
|
468
|
+
/** Frames to write to the client for one resume frame. */
|
|
469
|
+
feed(f) {
|
|
470
|
+
if (f.comment || f.dataText === null)
|
|
471
|
+
return [];
|
|
472
|
+
return this.shape === 'anthropic' ? this.feedAnthropic(f) : this.feedOpenAI(f);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* The resume stream ended WITHOUT its terminal event. Release whatever text
|
|
476
|
+
* was still held for the anchor check — it is the second provider's real
|
|
477
|
+
* output and the client may as well have it — but close nothing: no
|
|
478
|
+
* content_block_stop, no message_delta/message_stop, no finish chunk, no
|
|
479
|
+
* [DONE]. A synthesized clean end here would present a doubly-truncated
|
|
480
|
+
* answer as a complete one (review finding on #1286).
|
|
481
|
+
*/
|
|
482
|
+
abandon() {
|
|
483
|
+
return this.releaseHold(true);
|
|
484
|
+
}
|
|
485
|
+
// ---- anthropic --------------------------------------------------------
|
|
486
|
+
feedAnthropic(f) {
|
|
487
|
+
const d = f.data;
|
|
488
|
+
if (!d)
|
|
489
|
+
return [];
|
|
490
|
+
switch (d.type) {
|
|
491
|
+
case 'ping':
|
|
492
|
+
return [f.raw];
|
|
493
|
+
case 'message_start':
|
|
494
|
+
return []; // the client already has one
|
|
495
|
+
case 'content_block_start': {
|
|
496
|
+
const idx = d.index;
|
|
497
|
+
const cb = d.content_block;
|
|
498
|
+
const type = cb?.type ?? 'text';
|
|
499
|
+
if (type === 'thinking' || type === 'redacted_thinking') {
|
|
500
|
+
this.idxMap.set(idx, 'skip');
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
if (type === 'text' && !this.firstTextMapped) {
|
|
504
|
+
this.firstTextMapped = true;
|
|
505
|
+
if (this.clientOpenIdx >= 0 && this.clientOpenType === 'text') {
|
|
506
|
+
// Continue the block the client still has open — no start frame.
|
|
507
|
+
this.continuingIdx = this.clientOpenIdx;
|
|
508
|
+
this.idxMap.set(idx, this.continuingIdx);
|
|
509
|
+
return [];
|
|
510
|
+
}
|
|
511
|
+
// Nothing text-open on the client side: close whatever is open (a
|
|
512
|
+
// thinking block the cut fell in) and start a fresh text block.
|
|
513
|
+
const out = this.closeOriginal();
|
|
514
|
+
this.continuingIdx = this.nextIdx++;
|
|
515
|
+
this.idxMap.set(idx, this.continuingIdx);
|
|
516
|
+
out.push(formatFrame('content_block_start', { ...d, index: this.continuingIdx }));
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
// Any further block (a second text block, a tool_use): whatever is
|
|
520
|
+
// still open on the client side closes first — indices are sequential
|
|
521
|
+
// and only one block is open at a time.
|
|
522
|
+
const mapped = this.nextIdx++;
|
|
523
|
+
this.idxMap.set(idx, mapped);
|
|
524
|
+
const out = this.releaseHold(true);
|
|
525
|
+
out.push(...this.closeContinuing());
|
|
526
|
+
out.push(...this.closeOriginal());
|
|
527
|
+
out.push(formatFrame('content_block_start', { ...d, index: mapped }));
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
case 'content_block_delta': {
|
|
531
|
+
const m = this.idxMap.get(d.index);
|
|
532
|
+
if (m === undefined || m === 'skip')
|
|
533
|
+
return [];
|
|
534
|
+
const delta = d.delta;
|
|
535
|
+
if (m === this.continuingIdx && delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
|
536
|
+
this.hold += delta.text;
|
|
537
|
+
return this.releaseHold(false);
|
|
538
|
+
}
|
|
539
|
+
return [formatFrame('content_block_delta', { ...d, index: m })];
|
|
540
|
+
}
|
|
541
|
+
case 'content_block_stop': {
|
|
542
|
+
const m = this.idxMap.get(d.index);
|
|
543
|
+
if (m === undefined || m === 'skip')
|
|
544
|
+
return [];
|
|
545
|
+
if (m === this.continuingIdx) {
|
|
546
|
+
const out = this.releaseHold(true);
|
|
547
|
+
out.push(...this.closeContinuing());
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
return [formatFrame('content_block_stop', { ...d, index: m })];
|
|
551
|
+
}
|
|
552
|
+
case 'message_delta': {
|
|
553
|
+
this.stopReasonSeen = true;
|
|
554
|
+
const out = this.releaseHold(true);
|
|
555
|
+
out.push(...this.closeContinuing());
|
|
556
|
+
out.push(...this.closeOriginal());
|
|
557
|
+
out.push(f.raw);
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
case 'message_stop':
|
|
561
|
+
this.terminalSeen = true;
|
|
562
|
+
return [f.raw];
|
|
563
|
+
case 'error':
|
|
564
|
+
// The resume itself failed mid-way. Let the site's finish() see it
|
|
565
|
+
// as a dead stream: forwarding a second provider's error here would
|
|
566
|
+
// still leave the client with an open message, so close honestly.
|
|
567
|
+
return [];
|
|
568
|
+
default:
|
|
569
|
+
return [];
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/** Close the block the resume has been writing into, once. */
|
|
573
|
+
closeContinuing() {
|
|
574
|
+
if (this.continuingIdx < 0 || this.continuingClosed)
|
|
575
|
+
return [];
|
|
576
|
+
this.continuingClosed = true;
|
|
577
|
+
if (this.continuingIdx === this.clientOpenIdx)
|
|
578
|
+
this.originalClosed = true;
|
|
579
|
+
return [formatFrame('content_block_stop', { type: 'content_block_stop', index: this.continuingIdx })];
|
|
580
|
+
}
|
|
581
|
+
/** Close the block the client had open when the primary died, once, unless the resume is continuing it. */
|
|
582
|
+
closeOriginal() {
|
|
583
|
+
if (this.clientOpenIdx < 0 || this.originalClosed)
|
|
584
|
+
return [];
|
|
585
|
+
if (this.continuingIdx === this.clientOpenIdx && !this.continuingClosed)
|
|
586
|
+
return [];
|
|
587
|
+
this.originalClosed = true;
|
|
588
|
+
return [formatFrame('content_block_stop', { type: 'content_block_stop', index: this.clientOpenIdx })];
|
|
589
|
+
}
|
|
590
|
+
// ---- openai ------------------------------------------------------------
|
|
591
|
+
feedOpenAI(f) {
|
|
592
|
+
if (f.dataText === '[DONE]') {
|
|
593
|
+
this.doneSeen = true;
|
|
594
|
+
this.terminalSeen = true;
|
|
595
|
+
const out = this.releaseHold(true);
|
|
596
|
+
if (!this.stopReasonSeen) {
|
|
597
|
+
this.stopReasonSeen = true;
|
|
598
|
+
out.push(openaiChunk({}, 'stop'));
|
|
599
|
+
}
|
|
600
|
+
out.push(f.raw);
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
const d = f.data;
|
|
604
|
+
if (!d)
|
|
605
|
+
return [];
|
|
606
|
+
const choices = d.choices;
|
|
607
|
+
const c = choices?.[0];
|
|
608
|
+
if (!c)
|
|
609
|
+
return [];
|
|
610
|
+
const out = [];
|
|
611
|
+
if (typeof c.delta?.content === 'string' && c.delta.content.length > 0) {
|
|
612
|
+
this.hold += c.delta.content;
|
|
613
|
+
out.push(...this.releaseHold(false));
|
|
614
|
+
}
|
|
615
|
+
else if (c.delta?.tool_calls) {
|
|
616
|
+
out.push(...this.releaseHold(true));
|
|
617
|
+
out.push(openaiChunk({ tool_calls: c.delta.tool_calls }, null));
|
|
618
|
+
}
|
|
619
|
+
if (c.finish_reason) {
|
|
620
|
+
this.stopReasonSeen = true;
|
|
621
|
+
out.push(...this.releaseHold(true));
|
|
622
|
+
out.push(openaiChunk({}, c.finish_reason));
|
|
623
|
+
}
|
|
624
|
+
return out;
|
|
625
|
+
}
|
|
626
|
+
// ---- the hold ----------------------------------------------------------
|
|
627
|
+
/**
|
|
628
|
+
* Text from the resume's first text block is held until the anchor is
|
|
629
|
+
* found (or enough has arrived to give up looking), then trimmed and
|
|
630
|
+
* released. After that every delta streams straight through.
|
|
631
|
+
*/
|
|
632
|
+
releaseHold(final) {
|
|
633
|
+
if (this.holding) {
|
|
634
|
+
const found = findAnchor(this.partial, this.hold);
|
|
635
|
+
if (!found && !final && this.hold.length < HOLD_CHARS)
|
|
636
|
+
return [];
|
|
637
|
+
if (found) {
|
|
638
|
+
this.stats.anchor = found.exact ? 'exact' : 'fuzzy';
|
|
639
|
+
this.stats.dropped = found.cut;
|
|
640
|
+
this.hold = this.hold.slice(found.cut);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
const k = tailOverlap(this.partial, this.hold);
|
|
644
|
+
this.stats.anchor = k > 0 ? 'overlap' : 'none';
|
|
645
|
+
this.stats.dropped = k;
|
|
646
|
+
this.hold = this.hold.slice(k);
|
|
647
|
+
}
|
|
648
|
+
this.hold = fixSeam(this.partial, this.hold);
|
|
649
|
+
this.holding = false;
|
|
650
|
+
}
|
|
651
|
+
if (this.hold.length === 0)
|
|
652
|
+
return [];
|
|
653
|
+
const text = this.hold;
|
|
654
|
+
this.hold = '';
|
|
655
|
+
this.stats.emitted += text.length;
|
|
656
|
+
if (this.shape === 'anthropic') {
|
|
657
|
+
if (this.continuingIdx < 0 || this.continuingClosed)
|
|
658
|
+
return [];
|
|
659
|
+
return [formatFrame('content_block_delta', { type: 'content_block_delta', index: this.continuingIdx, delta: { type: 'text_delta', text } })];
|
|
660
|
+
}
|
|
661
|
+
return [openaiChunk({ content: text }, null)];
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function openaiChunk(delta, finish) {
|
|
665
|
+
return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: 'claude', choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`;
|
|
666
|
+
}
|
|
667
|
+
export class MidstreamGuard {
|
|
668
|
+
o;
|
|
669
|
+
state;
|
|
670
|
+
splitter = new SseFrameSplitter();
|
|
671
|
+
finished = false;
|
|
672
|
+
constructor(o) {
|
|
673
|
+
this.o = o;
|
|
674
|
+
this.state = new ClientStreamState(o.shape);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* The site knows the upstream turn failed (a codex `response.failed`, a
|
|
678
|
+
* terminal payload with an error status) before the translator's polite
|
|
679
|
+
* closing frames go out. From here on those frames are withheld, so the
|
|
680
|
+
* stream is treated as unfinished — and finished from the other provider.
|
|
681
|
+
*/
|
|
682
|
+
markUpstreamFailed() {
|
|
683
|
+
this.state.upstreamFailed = true;
|
|
684
|
+
}
|
|
685
|
+
/** Forward a client-bound chunk, withholding a terminal error frame. */
|
|
686
|
+
write(chunk) {
|
|
687
|
+
if (this.finished)
|
|
688
|
+
return;
|
|
689
|
+
for (const f of this.splitter.feed(chunk)) {
|
|
690
|
+
if (this.state.observe(f)) {
|
|
691
|
+
this.o.write(f.raw);
|
|
692
|
+
this.state.forwardedFrames++;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* End the client response. Replaces the site's `res.end()` on the streaming
|
|
698
|
+
* exits. Resolves once the client stream is closed either way.
|
|
699
|
+
*/
|
|
700
|
+
async finish() {
|
|
701
|
+
if (this.finished)
|
|
702
|
+
return 'ended';
|
|
703
|
+
this.finished = true;
|
|
704
|
+
const s = this.state;
|
|
705
|
+
const tail = this.splitter.flush();
|
|
706
|
+
const cleanEnd = () => {
|
|
707
|
+
if (tail.length > 0)
|
|
708
|
+
this.o.write(tail);
|
|
709
|
+
for (const f of s.withheld)
|
|
710
|
+
this.o.write(f.raw);
|
|
711
|
+
this.o.end();
|
|
712
|
+
};
|
|
713
|
+
if (s.finished || !s.started) {
|
|
714
|
+
cleanEnd();
|
|
715
|
+
return s.finished ? 'clean' : 'ended';
|
|
716
|
+
}
|
|
717
|
+
if (this.o.isClientGone()) {
|
|
718
|
+
this.o.end();
|
|
719
|
+
return 'ended';
|
|
720
|
+
}
|
|
721
|
+
if (!s.continuable || !this.o.resume) {
|
|
722
|
+
cleanEnd();
|
|
723
|
+
return 'not-continuable';
|
|
724
|
+
}
|
|
725
|
+
let target = null;
|
|
726
|
+
try {
|
|
727
|
+
target = await this.o.resume.resolveTarget();
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
target = null;
|
|
731
|
+
}
|
|
732
|
+
if (!target) {
|
|
733
|
+
this.log(`#${this.o.requestNo} stream died after ${s.textSoFar.length} chars — no continuation target (set --pool-fallback with an entry for the other provider)`);
|
|
734
|
+
cleanEnd();
|
|
735
|
+
return 'no-target';
|
|
736
|
+
}
|
|
737
|
+
const partial = s.textSoFar;
|
|
738
|
+
this.log(`#${this.o.requestNo} stream died after ${partial.length} chars → continuing as ${target.label}`);
|
|
739
|
+
const outcome = await this.continueFrom(target, partial);
|
|
740
|
+
if (outcome === 'failed') {
|
|
741
|
+
cleanEnd();
|
|
742
|
+
return 'resume-failed';
|
|
743
|
+
}
|
|
744
|
+
this.o.end();
|
|
745
|
+
return outcome === 'finished' ? 'continued' : 'continued-unfinished';
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* 'failed': nothing of the resume reached the client — the site ends the
|
|
749
|
+
* stream exactly as it would have. 'finished': the resume delivered its
|
|
750
|
+
* terminal event and the client holds one complete message. 'unfinished':
|
|
751
|
+
* the resume put content on the wire and then died too; the stream is left
|
|
752
|
+
* open-ended (no synthesized close) so the client sees the truncation.
|
|
753
|
+
*/
|
|
754
|
+
async continueFrom(target, partial) {
|
|
755
|
+
const r = this.o.resume;
|
|
756
|
+
const s = this.state;
|
|
757
|
+
const fetchImpl = r.fetchImpl ?? fetch;
|
|
758
|
+
const path = this.o.shape === 'anthropic' ? '/v1/messages' : '/v1/chat/completions';
|
|
759
|
+
const clientBody = r.clientBody();
|
|
760
|
+
if (!clientBody) {
|
|
761
|
+
this.log(`#${this.o.requestNo} continuation skipped: client body is not a JSON object`);
|
|
762
|
+
return 'failed';
|
|
763
|
+
}
|
|
764
|
+
const body = buildResumeBody(this.o.shape, clientBody, target.model, partial);
|
|
765
|
+
const splicer = new Splicer(this.o.shape, s, partial);
|
|
766
|
+
const abort = new AbortController();
|
|
767
|
+
const timer = setTimeout(() => abort.abort(), r.timeoutMs);
|
|
768
|
+
const startedAt = Date.now();
|
|
769
|
+
try {
|
|
770
|
+
r.onBeforeResume?.();
|
|
771
|
+
const res = await fetchImpl(`${r.loopbackBase}${path}`, {
|
|
772
|
+
method: 'POST',
|
|
773
|
+
headers: { 'content-type': 'application/json', [CONTINUATION_HEADER]: String(this.o.requestNo), ...r.loopbackHeaders },
|
|
774
|
+
body: JSON.stringify(body),
|
|
775
|
+
signal: abort.signal,
|
|
776
|
+
});
|
|
777
|
+
if (!res.ok || !res.body) {
|
|
778
|
+
const detail = await res.text().catch(() => '');
|
|
779
|
+
this.log(`#${this.o.requestNo} continuation refused: HTTP ${res.status} ${detail.slice(0, 200)}`);
|
|
780
|
+
return 'failed';
|
|
781
|
+
}
|
|
782
|
+
// An SSE comment, ignored by every parser, so a raw capture shows where
|
|
783
|
+
// the second provider took over.
|
|
784
|
+
this.o.write(`: dario continuation ${target.label} after ${partial.length} chars\n\n`);
|
|
785
|
+
const reader = res.body.getReader();
|
|
786
|
+
const split = new SseFrameSplitter();
|
|
787
|
+
let sawContent = false;
|
|
788
|
+
let resumeError = null;
|
|
789
|
+
try {
|
|
790
|
+
outer: while (true) {
|
|
791
|
+
const { done, value } = await reader.read();
|
|
792
|
+
if (done)
|
|
793
|
+
break;
|
|
794
|
+
if (this.o.isClientGone()) {
|
|
795
|
+
abort.abort();
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
for (const f of split.feed(value)) {
|
|
799
|
+
if (f.data && (f.data.type === 'error' || f.data.error)) {
|
|
800
|
+
resumeError = f;
|
|
801
|
+
break outer;
|
|
802
|
+
}
|
|
803
|
+
for (const out of splicer.feed(f)) {
|
|
804
|
+
this.o.write(out);
|
|
805
|
+
sawContent = true;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
finally {
|
|
811
|
+
try {
|
|
812
|
+
reader.releaseLock();
|
|
813
|
+
}
|
|
814
|
+
catch { /* released by abort */ }
|
|
815
|
+
}
|
|
816
|
+
const st = splicer.stats;
|
|
817
|
+
if (resumeError) {
|
|
818
|
+
// The second provider failed too. Nothing spliced yet: report failure
|
|
819
|
+
// so the site ends the stream exactly as before. Something spliced:
|
|
820
|
+
// forward this error as the terminal frame — closing the message with
|
|
821
|
+
// a synthetic end_turn would make a truncated answer look finished.
|
|
822
|
+
if (!sawContent)
|
|
823
|
+
return 'failed';
|
|
824
|
+
this.o.write(resumeError.raw);
|
|
825
|
+
this.log(`#${this.o.requestNo} continuation died too after +${st.emitted} chars`);
|
|
826
|
+
return 'unfinished';
|
|
827
|
+
}
|
|
828
|
+
if (splicer.terminalSeen) {
|
|
829
|
+
this.log(`#${this.o.requestNo} continuation done: +${st.emitted} chars in ${Date.now() - startedAt}ms (anchor ${st.anchor}, trimmed ${st.dropped})`);
|
|
830
|
+
return 'finished';
|
|
831
|
+
}
|
|
832
|
+
// The resume body ended without its terminal event — a reset on the
|
|
833
|
+
// second provider, or the client left and the loopback was aborted.
|
|
834
|
+
// Hand over what was held and stop there: no synthesized close.
|
|
835
|
+
for (const out of splicer.abandon()) {
|
|
836
|
+
this.o.write(out);
|
|
837
|
+
sawContent = true;
|
|
838
|
+
}
|
|
839
|
+
if (!sawContent)
|
|
840
|
+
return 'failed';
|
|
841
|
+
this.log(`#${this.o.requestNo} continuation ended without its terminal event after +${st.emitted} chars — stream left unfinished`);
|
|
842
|
+
return 'unfinished';
|
|
843
|
+
}
|
|
844
|
+
catch (err) {
|
|
845
|
+
this.log(`#${this.o.requestNo} continuation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
846
|
+
return 'failed';
|
|
847
|
+
}
|
|
848
|
+
finally {
|
|
849
|
+
clearTimeout(timer);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
log(line) {
|
|
853
|
+
(this.o.log ?? ((l) => console.log(`[dario] ${l}`)))(line);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/** Convenience for sites that hold a ServerResponse: the guard writes through `write`, ends through `res.end()`. */
|
|
857
|
+
export function guardFor(res, o) {
|
|
858
|
+
return new MidstreamGuard({ ...o, end: () => { if (!res.writableEnded)
|
|
859
|
+
res.end(); } });
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* The loopback origin for a bound listen address. A wildcard bind is reached
|
|
863
|
+
* on the loopback interface; a specific address is reached on itself.
|
|
864
|
+
*/
|
|
865
|
+
export function loopbackBaseFor(host, port) {
|
|
866
|
+
const h = host.trim().toLowerCase();
|
|
867
|
+
if (h === '' || h === '0.0.0.0' || h === '127.0.0.1' || h === 'localhost')
|
|
868
|
+
return `http://127.0.0.1:${port}`;
|
|
869
|
+
if (h === '::' || h === '::1')
|
|
870
|
+
return `http://[::1]:${port}`;
|
|
871
|
+
return h.includes(':') ? `http://[${h}]:${port}` : `http://${h}:${port}`;
|
|
872
|
+
}
|