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