@askalf/dario 6.1.0 → 6.2.1

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 CHANGED
@@ -289,7 +289,7 @@ That is a **chain**, read left to right; each provider takes the first entry it
289
289
 
290
290
  A single-entry chain is one-way and means what it always meant, so an existing config is unaffected. Failover is opt-in: without `--pool-fallback`, a drained pool still returns its honest 429/503. Only a **429 or 5xx** fails over; a 400 surfaces, because a bad request that fails over just reproduces itself on the other provider and buries the real cause. A 429 also cools that provider for a bounded interval, its `retry-after` if it sent one and 60 s otherwise, never longer than 15 min, and an entry that already declined is not asked again within the same request. When every entry is cooling, the request ends on one honest `429` with a `retry-after` instead of a retry storm. The Claude entry has to be a model the pool can actually serve, checked positively against the live catalog, so a typo can't trade a recoverable 429 for an unrecoverable 404.
291
291
 
292
- The chain also covers a stream that dies **mid-answer**. Until 6.1 that was the one failure nothing could catch: bytes were on the wire, so the socket reset, the in-band `overloaded_error`, the codex `response.failed` all ended the stream where they happened, with no `message_stop`, and the client threw away every word it already had. Now dario finishes the same stream from the other subscription the resume picks up inside the still-open content block, a comment marks the seam (`: dario continuation gpt-5.6-sol (codex live) after 1240 chars`), and the client sees one message. The model is asked to repeat the last few words verbatim and dario trims the repeat, so the join is rendered by a model and cut by a parser, never guessed. Text only — a cut inside a tool call ends as it always did. On by default, `--no-midstream-continue` turns it off, and without a chain entry for the other provider it is inert. [How it works](docs/midstream-continuation.md).
292
+ The chain also covers a stream that dies **mid-answer**. Until 6.1 that was the one failure nothing could catch: bytes were on the wire, so the socket reset, the in-band `overloaded_error`, the codex `response.failed` all ended the stream where they happened, with no `message_stop`, and the client threw away every word it already had. Now dario finishes the same stream on the same model first, a fresh request through its own front door; on the other subscription when that delivers nothing or dies too. The resume picks up inside the still-open content block, a comment marks each seam (`: dario continuation claude-opus-5 (same model) after 1240 chars`), and the client sees one message. The model is asked to repeat the last few words verbatim and dario trims the repeat, so the join is rendered by a model and cut by a parser, never guessed. Text only — a cut inside a tool call ends as it always did. On by default, `--no-midstream-continue` turns it off; without a chain the second hop is simply not there. [How it works](docs/midstream-continuation.md).
293
293
 
294
294
  `dario doctor` tells you which of these you are actually in:
295
295
 
@@ -37,6 +37,21 @@ export interface Check {
37
37
  * live request, and this release was built on the lesson that a green config is
38
38
  * not a working path.
39
39
  */
40
+ /**
41
+ * Mid-stream continuation readiness (v6.2.1). Configuration only, like
42
+ * failoverReadiness: it says which of the two hops a dying stream can take on
43
+ * this host, not that either works. The first hop (the same model again) needs
44
+ * nothing; the second (the other subscription) needs the failover chain AND
45
+ * somewhere for it to go — the exact INERT state the Failover row exists for.
46
+ */
47
+ export declare function continuationReadiness(input: {
48
+ enabled: boolean;
49
+ chain: readonly string[];
50
+ codexAccounts: number;
51
+ }): {
52
+ status: CheckStatus;
53
+ detail: string;
54
+ };
40
55
  export declare function failoverReadiness(input: {
41
56
  chain: readonly string[];
42
57
  codexAccounts: number;
@@ -41,6 +41,29 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
41
41
  * live request, and this release was built on the lesson that a green config is
42
42
  * not a working path.
43
43
  */
44
+ /**
45
+ * Mid-stream continuation readiness (v6.2.1). Configuration only, like
46
+ * failoverReadiness: it says which of the two hops a dying stream can take on
47
+ * this host, not that either works. The first hop (the same model again) needs
48
+ * nothing; the second (the other subscription) needs the failover chain AND
49
+ * somewhere for it to go — the exact INERT state the Failover row exists for.
50
+ */
51
+ export function continuationReadiness(input) {
52
+ if (!input.enabled) {
53
+ return { status: 'info', detail: 'off — a stream that dies mid-answer ends truncated (unset DARIO_MIDSTREAM_CONTINUE / drop --no-midstream-continue)' };
54
+ }
55
+ const secondHop = input.chain.length > 0 && input.codexAccounts > 0;
56
+ if (secondHop) {
57
+ return { status: 'ok', detail: `on: a dying stream resumes on the same model, then on ${input.chain.join(' → ')} (two hops)` };
58
+ }
59
+ return {
60
+ status: 'ok',
61
+ detail: 'on: a dying stream resumes on the same model only — '
62
+ + (input.chain.length === 0
63
+ ? 'add --pool-fallback for a second hop on the other subscription'
64
+ : 'the chain has nowhere to go for a second hop (see Failover)'),
65
+ };
66
+ }
44
67
  export function failoverReadiness(input) {
45
68
  const { chain, codexAccounts, backends } = input;
46
69
  const hasCodex = codexAccounts > 0;
@@ -1170,6 +1193,9 @@ export async function runChecks(opts = {}) {
1170
1193
  backends: backends.map((b) => b.name),
1171
1194
  });
1172
1195
  checks.push({ status: verdict.status, label: 'Failover', detail: verdict.detail });
1196
+ const midstreamEnabled = !['0', 'false', 'no', 'off'].includes((process.env.DARIO_MIDSTREAM_CONTINUE ?? '').toLowerCase());
1197
+ const cont = continuationReadiness({ enabled: midstreamEnabled, chain, codexAccounts: codexAliases.length });
1198
+ checks.push({ status: cont.status, label: 'Continuation', detail: cont.detail });
1173
1199
  }
1174
1200
  catch (err) {
1175
1201
  checks.push({ status: 'warn', label: 'Failover', detail: `check failed: ${err.message}` });
@@ -49,8 +49,22 @@
49
49
  */
50
50
  import type { ServerResponse } from 'node:http';
51
51
  export type WireShape = 'anthropic' | 'openai';
52
- /** Client-visible marker that a loopback request is a continuation, so the handler never nests one. */
52
+ /**
53
+ * Marks a loopback request as a continuation and carries its DEPTH: `1` for
54
+ * the resume of a client request, `2` for the resume of that resume. A
55
+ * request at `MAX_CONTINUATION_DEPTH` is never continued itself.
56
+ */
53
57
  export declare const CONTINUATION_HEADER = "x-dario-continuation";
58
+ /**
59
+ * Two hops. The first resume asks for the SAME model again through the front
60
+ * door — the pool picks a seat and the existing pre-byte failover applies —
61
+ * so a transient reset finishes on the model the client asked for. If that
62
+ * resume dies too, the second hop goes to the OTHER provider's chain entry.
63
+ * A third hop would be a third attempt at whatever is failing.
64
+ */
65
+ export declare const MAX_CONTINUATION_DEPTH = 2;
66
+ /** The depth a request carries, 0 for an ordinary client request. */
67
+ export declare function continuationDepth(headerValue: string | string[] | undefined): number;
54
68
  /** Characters of the partial the model is asked to repeat verbatim (the seam anchor). */
55
69
  export declare const ANCHOR_CHARS = 40;
56
70
  export interface SseFrame {
@@ -219,6 +233,8 @@ export declare class Splicer {
219
233
  dropped: number;
220
234
  emitted: number;
221
235
  };
236
+ /** `message_start.model` of the resume — who actually served it. */
237
+ resumeModel: string | null;
222
238
  constructor(shape: WireShape, state: ClientStreamState, partial: string);
223
239
  /** Frames to write to the client for one resume frame. */
224
240
  feed(f: SseFrame): string[];
@@ -261,8 +277,13 @@ export interface ResumeOptions {
261
277
  loopbackBase: string;
262
278
  /** Auth + attribution headers for the loopback request. */
263
279
  loopbackHeaders: Record<string, string>;
264
- /** Decides where to resume, once, at failure time. Null = nowhere; the stream ends as before. */
265
- resolveTarget: () => Promise<ContinuationTarget | null>;
280
+ /**
281
+ * Where to resume, decided at failure time. `choice` is 1 for the same
282
+ * model again and 2 for the other provider's chain entry; the guard asks
283
+ * in that order and moves on when a choice is null or its loopback delivers
284
+ * nothing. Null for every choice = the stream ends as before.
285
+ */
286
+ resolveTarget: (choice: number) => Promise<ContinuationTarget | null>;
266
287
  /** Called right before the loopback request is made — the site releases its own queue slot here. */
267
288
  onBeforeResume?: () => void;
268
289
  timeoutMs: number;
@@ -277,6 +298,8 @@ export interface MidstreamGuardOptions {
277
298
  isClientGone: () => boolean;
278
299
  resume: ResumeOptions | null;
279
300
  requestNo: number;
301
+ /** Depth of the request THIS guard protects (0 = a client request). Its resume is `depth + 1`. */
302
+ depth?: number;
280
303
  verbose: boolean;
281
304
  log?: (line: string) => void;
282
305
  }
@@ -311,6 +334,34 @@ export declare class MidstreamGuard {
311
334
  private continueFrom;
312
335
  private log;
313
336
  }
337
+ export interface ChaosCutOptions {
338
+ /** Characters of answer text an upstream stream is allowed before it is cut. */
339
+ afterChars: number;
340
+ /** How many streams to cut before the tap goes quiet (default 1). */
341
+ streams?: number;
342
+ log?: (line: string) => void;
343
+ }
344
+ /**
345
+ * The remaining-cuts counter, shared by every wrapper the proxy makes. The
346
+ * Claude leg and the codex leg wrap different fetch implementations, and a
347
+ * counter per wrapper would cut up to twice the promised number of streams
348
+ * (review finding on #1290): one budget for the proxy, not one per provider.
349
+ */
350
+ export interface ChaosCutState {
351
+ left: number;
352
+ }
353
+ export declare function chaosCutState(o: ChaosCutOptions): ChaosCutState;
354
+ /**
355
+ * Wraps an upstream fetch so that the first `streams` streamed answers die
356
+ * after `afterChars` characters of text — the failure this module exists for,
357
+ * on demand. A resume (its body carries the anchor quote) is never cut, so
358
+ * the tap produces a primary death and lets the continuation play out.
359
+ *
360
+ * Demo and test affordance, never a default: `DARIO_CHAOS_CUT_AFTER=300
361
+ * dario proxy` then stream any request and watch the seam. Both providers'
362
+ * text framing is recognised (`text_delta` / `response.output_text.delta`).
363
+ */
364
+ export declare function chaosCutFetch(inner: typeof fetch, o: ChaosCutOptions, state?: ChaosCutState): typeof fetch;
314
365
  /** Convenience for sites that hold a ServerResponse: the guard writes through `write`, ends through `res.end()`. */
315
366
  export declare function guardFor(res: ServerResponse, o: Omit<MidstreamGuardOptions, 'end'>): MidstreamGuard;
316
367
  /**
package/dist/midstream.js CHANGED
@@ -47,8 +47,28 @@
47
47
  * the existing pre-byte failover covers them), and the api-key OpenAI backend.
48
48
  * A stream that cannot be continued ends exactly as it did before this module.
49
49
  */
50
- /** Client-visible marker that a loopback request is a continuation, so the handler never nests one. */
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
+ */
51
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
+ }
52
72
  /** Characters of the partial the model is asked to repeat verbatim (the seam anchor). */
53
73
  export const ANCHOR_CHARS = 40;
54
74
  /** Upper bound on continuation text held back while looking for the anchor. */
@@ -457,6 +477,8 @@ export class Splicer {
457
477
  terminalSeen = false;
458
478
  /** Diagnostics for the log line. */
459
479
  stats = { anchor: 'n/a', dropped: 0, emitted: 0 };
480
+ /** `message_start.model` of the resume — who actually served it. */
481
+ resumeModel = null;
460
482
  constructor(shape, state, partial) {
461
483
  this.shape = shape;
462
484
  this.partial = partial;
@@ -467,7 +489,11 @@ export class Splicer {
467
489
  }
468
490
  /** Frames to write to the client for one resume frame. */
469
491
  feed(f) {
470
- if (f.comment || f.dataText === null)
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)
471
497
  return [];
472
498
  return this.shape === 'anthropic' ? this.feedAnthropic(f) : this.feedOpenAI(f);
473
499
  }
@@ -490,8 +516,12 @@ export class Splicer {
490
516
  switch (d.type) {
491
517
  case 'ping':
492
518
  return [f.raw];
493
- case 'message_start':
519
+ case 'message_start': {
520
+ const m = d.message;
521
+ if (typeof m?.model === 'string')
522
+ this.resumeModel = m.model;
494
523
  return []; // the client already has one
524
+ }
495
525
  case 'content_block_start': {
496
526
  const idx = d.index;
497
527
  const cb = d.content_block;
@@ -607,6 +637,8 @@ export class Splicer {
607
637
  const c = choices?.[0];
608
638
  if (!c)
609
639
  return [];
640
+ if (typeof d.model === 'string' && !this.resumeModel)
641
+ this.resumeModel = d.model;
610
642
  const out = [];
611
643
  if (typeof c.delta?.content === 'string' && c.delta.content.length > 0) {
612
644
  this.hold += c.delta.content;
@@ -661,6 +693,7 @@ export class Splicer {
661
693
  return [openaiChunk({ content: text }, null)];
662
694
  }
663
695
  }
696
+ const SEAM_COMMENT_PREFIX = ': dario continuation';
664
697
  function openaiChunk(delta, finish) {
665
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`;
666
699
  }
@@ -722,27 +755,41 @@ export class MidstreamGuard {
722
755
  cleanEnd();
723
756
  return 'not-continuable';
724
757
  }
725
- let target = null;
726
- try {
727
- target = await this.o.resume.resolveTarget();
728
- }
729
- catch {
730
- target = null;
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';
731
785
  }
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)`);
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)`);
734
788
  cleanEnd();
735
789
  return 'no-target';
736
790
  }
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';
791
+ cleanEnd();
792
+ return 'resume-failed';
746
793
  }
747
794
  /**
748
795
  * 'failed': nothing of the resume reached the client — the site ends the
@@ -770,7 +817,7 @@ export class MidstreamGuard {
770
817
  r.onBeforeResume?.();
771
818
  const res = await fetchImpl(`${r.loopbackBase}${path}`, {
772
819
  method: 'POST',
773
- headers: { 'content-type': 'application/json', [CONTINUATION_HEADER]: String(this.o.requestNo), ...r.loopbackHeaders },
820
+ headers: { 'content-type': 'application/json', [CONTINUATION_HEADER]: String((this.o.depth ?? 0) + 1), ...r.loopbackHeaders },
774
821
  body: JSON.stringify(body),
775
822
  signal: abort.signal,
776
823
  });
@@ -781,7 +828,7 @@ export class MidstreamGuard {
781
828
  }
782
829
  // An SSE comment, ignored by every parser, so a raw capture shows where
783
830
  // the second provider took over.
784
- this.o.write(`: dario continuation ${target.label} after ${partial.length} chars\n\n`);
831
+ this.o.write(`${SEAM_COMMENT_PREFIX} ${target.label} after ${partial.length} chars\n\n`);
785
832
  const reader = res.body.getReader();
786
833
  const split = new SseFrameSplitter();
787
834
  let sawContent = false;
@@ -826,7 +873,8 @@ export class MidstreamGuard {
826
873
  return 'unfinished';
827
874
  }
828
875
  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})`);
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})`);
830
878
  return 'finished';
831
879
  }
832
880
  // The resume body ended without its terminal event — a reset on the
@@ -853,6 +901,62 @@ export class MidstreamGuard {
853
901
  (this.o.log ?? ((l) => console.log(`[dario] ${l}`)))(line);
854
902
  }
855
903
  }
904
+ export function chaosCutState(o) {
905
+ return { left: o.streams ?? 1 };
906
+ }
907
+ /**
908
+ * Wraps an upstream fetch so that the first `streams` streamed answers die
909
+ * after `afterChars` characters of text — the failure this module exists for,
910
+ * on demand. A resume (its body carries the anchor quote) is never cut, so
911
+ * the tap produces a primary death and lets the continuation play out.
912
+ *
913
+ * Demo and test affordance, never a default: `DARIO_CHAOS_CUT_AFTER=300
914
+ * dario proxy` then stream any request and watch the seam. Both providers'
915
+ * text framing is recognised (`text_delta` / `response.output_text.delta`).
916
+ */
917
+ export function chaosCutFetch(inner, o, state = chaosCutState(o)) {
918
+ const log = o.log ?? ((l) => console.warn(`[dario] ${l}`));
919
+ return async (input, init) => {
920
+ const res = await inner(input, init);
921
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
922
+ const isStream = /\/v1\/messages|\/responses/.test(url);
923
+ const bodyText = typeof init?.body === 'string' ? init.body : init?.body instanceof Uint8Array ? new TextDecoder().decode(init.body) : '';
924
+ const isResume = bodyText.includes(ANCHOR_OPEN);
925
+ if (!isStream || isResume || state.left <= 0 || res.status !== 200 || !res.body)
926
+ return res;
927
+ state.left--;
928
+ const reader = res.body.getReader();
929
+ const dec = new TextDecoder();
930
+ let text = '';
931
+ const body = new ReadableStream({
932
+ async pull(c) {
933
+ const { done, value } = await reader.read();
934
+ if (done) {
935
+ c.close();
936
+ return;
937
+ }
938
+ c.enqueue(value);
939
+ for (const m of dec.decode(value, { stream: true }).matchAll(/"(?:text|delta)":"((?:[^"\\]|\\.)*)"/g)) {
940
+ try {
941
+ text += JSON.parse(`"${m[1]}"`);
942
+ }
943
+ catch { /* not a text fragment */ }
944
+ }
945
+ if (text.length >= o.afterChars) {
946
+ log(`CHAOS: cutting this stream after ${text.length} chars (${state.left} more to go)`);
947
+ await new Promise((r) => setTimeout(r, 30)); // let what is queued reach the reader first
948
+ try {
949
+ await reader.cancel();
950
+ }
951
+ catch { /* already gone */ }
952
+ c.error(new Error('chaos: read ECONNRESET'));
953
+ }
954
+ },
955
+ cancel() { reader.cancel().catch(() => { }); },
956
+ });
957
+ return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
958
+ };
959
+ }
856
960
  /** Convenience for sites that hold a ServerResponse: the guard writes through `write`, ends through `res.end()`. */
857
961
  export function guardFor(res, o) {
858
962
  return new MidstreamGuard({ ...o, end: () => { if (!res.writableEnded)
package/dist/proxy.js CHANGED
@@ -26,7 +26,7 @@ import { createTokenBucket } from './rate-limit.js';
26
26
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
27
27
  import { forwardToCodex, getCodexModelSlugs, peekCodexModelSlugs, isCodexModel, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
28
28
  import { effortForCodex } from './effort.js';
29
- import { MidstreamGuard, guardFor, loopbackBaseFor, CONTINUATION_HEADER } from './midstream.js';
29
+ import { MidstreamGuard, guardFor, loopbackBaseFor, chaosCutFetch, chaosCutState, CONTINUATION_HEADER, MAX_CONTINUATION_DEPTH, continuationDepth } from './midstream.js';
30
30
  import { isClaudeServableModel } from './claude-model.js';
31
31
  import { MODEL_UNROUTABLE } from './upstream-rejection.js';
32
32
  import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
@@ -1093,7 +1093,22 @@ export async function startProxy(opts = {}) {
1093
1093
  // Upstream auth override: a per-token API key forwards to the standard API
1094
1094
  // pool via `x-api-key`, bypassing OAuth/Max + the account pool entirely.
1095
1095
  // Env-only so the key never lands in `ps`/argv. Default (empty) = OAuth/Max.
1096
- const upstreamFetch = opts.fetchImpl ?? fetch;
1096
+ // DARIO_CHAOS_CUT_AFTER=<chars> [DARIO_CHAOS_CUT_STREAMS=<n>]: the first n
1097
+ // streamed answers die on purpose after that many characters, so the
1098
+ // mid-stream continuation can be watched on demand. Demo and test only —
1099
+ // loud at startup, never a default. Applied to the codex leg as well.
1100
+ const chaosCutAfter = Number.parseInt(process.env.DARIO_CHAOS_CUT_AFTER ?? '', 10);
1101
+ const chaosCut = Number.isFinite(chaosCutAfter) && chaosCutAfter > 0
1102
+ ? { afterChars: chaosCutAfter, streams: Math.max(1, Number.parseInt(process.env.DARIO_CHAOS_CUT_STREAMS ?? '1', 10) || 1) }
1103
+ : null;
1104
+ if (chaosCut)
1105
+ console.warn(`[dario] ⚠ CHAOS: the first ${chaosCut.streams} streamed answer${chaosCut.streams === 1 ? '' : 's'} will be cut after ${chaosCut.afterChars} chars (DARIO_CHAOS_CUT_AFTER) — demo/test only`);
1106
+ // One cut budget for the whole proxy. The two legs wrap different fetch
1107
+ // implementations (the Claude leg honours opts.fetchImpl, the codex leg is
1108
+ // the global fetch), so the counter lives outside both wrappers.
1109
+ const chaosState = chaosCut ? chaosCutState(chaosCut) : null;
1110
+ const upstreamFetch = chaosCut && chaosState ? chaosCutFetch(opts.fetchImpl ?? fetch, chaosCut, chaosState) : (opts.fetchImpl ?? fetch);
1111
+ const codexFetch = chaosCut && chaosState ? chaosCutFetch(fetch, chaosCut, chaosState) : fetch;
1097
1112
  const upstreamApiKey = (opts.upstreamApiKey ?? process.env.ANTHROPIC_UPSTREAM_API_KEY ?? '').trim();
1098
1113
  if (upstreamApiKey)
1099
1114
  console.error('[dario] upstream auth: per-token API key (x-api-key) — OAuth/Max + account pool bypassed');
@@ -2952,9 +2967,21 @@ export async function startProxy(opts = {}) {
2952
2967
  // re-issues the CLIENT's request, not the rewritten one, so dario's own
2953
2968
  // rules apply to the resume the same way they applied to the original.
2954
2969
  const clientBodyBytes = body;
2955
- // A loopback request made by a continuation. Never continued itself
2956
- // one resume per client request, no nesting.
2957
- const isContinuation = req.headers[CONTINUATION_HEADER] !== undefined;
2970
+ // How deep in a continuation chain this request sits: 0 for a client
2971
+ // request, 1 for its resume, 2 for the resume of that resume — which is
2972
+ // never continued itself (MAX_CONTINUATION_DEPTH).
2973
+ const requestDepth = continuationDepth(req.headers[CONTINUATION_HEADER]);
2974
+ const isContinuation = requestDepth >= MAX_CONTINUATION_DEPTH;
2975
+ /**
2976
+ * First hop: the SAME model again, through the front door. The pool
2977
+ * picks a seat (sticky binding keeps the prompt cache warm), and if the
2978
+ * provider cannot take it at all the existing pre-byte failover already
2979
+ * hands it to the other one. Null when the client named no model.
2980
+ */
2981
+ const sameModelTarget = () => {
2982
+ const m = parseClientBody()?.model;
2983
+ return typeof m === 'string' && m.length > 0 ? { model: m, label: `${m} (same model)` } : null;
2984
+ };
2958
2985
  const loopbackHeaders = () => {
2959
2986
  const h = {};
2960
2987
  if (apiKey)
@@ -3373,18 +3400,19 @@ export async function startProxy(opts = {}) {
3373
3400
  res.write(chunk); },
3374
3401
  isClientGone: () => res.destroyed || res.writableEnded,
3375
3402
  requestNo: codexReq,
3403
+ depth: requestDepth,
3376
3404
  verbose,
3377
3405
  resume: {
3378
3406
  clientBody: parseClientBody,
3379
3407
  loopbackBase,
3380
3408
  loopbackHeaders: loopbackHeaders(),
3381
- resolveTarget: async () => claudeContinuation,
3409
+ resolveTarget: async (hop) => hop === 1 ? sameModelTarget() : claudeContinuation,
3382
3410
  onBeforeResume: releaseQueueSlot,
3383
3411
  timeoutMs: upstreamTimeoutMs,
3384
3412
  },
3385
3413
  })
3386
3414
  : null;
3387
- const served = codexAvailable && await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', fetch, canDefer,
3415
+ const served = codexAvailable && await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', codexFetch, canDefer,
3388
3416
  // Before this hook a codex request left no trace: nothing in
3389
3417
  // /analytics, nothing in the request log, no per-account count.
3390
3418
  // The dock (and anyone reading /analytics) saw a proxy that
@@ -4704,12 +4732,13 @@ export async function startProxy(opts = {}) {
4704
4732
  res.end(); },
4705
4733
  isClientGone: () => clientDisconnected || res.destroyed || upstreamAbortReason === 'client_closed' || upstreamAbortReason === 'sse_overflow',
4706
4734
  requestNo: requestCount,
4735
+ depth: requestDepth,
4707
4736
  verbose,
4708
4737
  resume: {
4709
4738
  clientBody: parseClientBody,
4710
4739
  loopbackBase,
4711
4740
  loopbackHeaders: loopbackHeaders(),
4712
- resolveTarget: codexContinuationTarget,
4741
+ resolveTarget: async (hop) => hop === 1 ? sameModelTarget() : codexContinuationTarget(),
4713
4742
  onBeforeResume: releaseQueueSlot,
4714
4743
  timeoutMs: upstreamTimeoutMs,
4715
4744
  },
@@ -46,23 +46,34 @@ Non-streaming requests are untouched; nothing was on the wire.
46
46
 
47
47
  Through dario's own front door. The resume is a loopback `POST` to the same
48
48
  proxy, so the pool, the codex translator, cch, the template, every rule that
49
- applied to the original request applies to the resume. The request carries
50
- `x-dario-continuation` so it is never itself continued — one resume per client
51
- request, no nesting.
52
-
53
- The target is the other provider's entry in `--pool-fallback`, exactly the entry
54
- a mid-flight 429 would use:
55
-
56
- - a Claude stream resumes on the codex half of the chain (`gpt-5.6-sol` in
57
- `--pool-fallback=gpt-5.6-sol,claude:claude-sonnet-5`), resolved at failure
58
- time against the account's live model list;
59
- - a codex stream resumes on the Claude half (`claude-sonnet-5` above), resolved
60
- against the live catalog the way the chain already is.
61
-
62
- With no chain, or no entry for the other provider, the stream ends exactly as it
63
- did before 6.1, and the log says why once:
49
+ applied to the original request applies to the resume.
50
+
51
+ Two choices, in order:
52
+
53
+ 1. **The same model again.** A fresh request for the model the client asked
54
+ for. The pool picks a seat the sticky binding keeps the prompt cache warm
55
+ — and if the provider cannot take the request at all, the existing pre-byte
56
+ failover already hands it to the other one. A transient reset therefore
57
+ finishes on the model the user chose, with no chain configured. This is
58
+ what most streams that die get, and most users have one plan.
59
+ 2. **The other provider's entry in `--pool-fallback`**, exactly the entry a
60
+ mid-flight 429 would use: a Claude stream goes to the codex half of the
61
+ chain (`gpt-5.6-sol` in `--pool-fallback=gpt-5.6-sol,claude:claude-sonnet-5`),
62
+ resolved at failure time against the account's live model list; a codex
63
+ stream goes to the Claude half, resolved against the live catalog.
64
+
65
+ Choice 2 is taken when choice 1 delivers nothing — refused, unreachable, dead
66
+ before its first byte — or when the resume itself dies mid-way. In the second
67
+ case the resume's own guard makes the hop, so the client stream carries two
68
+ seams: `(same model)` then `(codex live)`. The loopback carries
69
+ `x-dario-continuation: <depth>`; a request at depth 2 is never continued. Two
70
+ hops, never three: a third would be a third attempt at whatever is failing.
71
+
72
+ With only one plan and no chain, a stream whose same-model resume also fails
73
+ ends where the resume stopped, and the log says why once:
64
74
 
65
75
  ```
76
+ [dario] #42 continuation as claude-opus-5 (same model) delivered nothing — trying the next choice
66
77
  [dario] #42 stream died after 1240 chars — no continuation target (set --pool-fallback with an entry for the other provider)
67
78
  ```
68
79
 
@@ -120,9 +131,35 @@ is never closed with a synthetic `end_turn`; only the resume's own
120
131
  |---|---|
121
132
  | `--no-midstream-continue` | off for this proxy |
122
133
  | `DARIO_MIDSTREAM_CONTINUE=0` | same, for the container |
123
- | `--pool-fallback=…` | where a stream resumes; no entry for the other provider means no resume |
134
+ | `--pool-fallback=…` | where the second hop goes; without an entry for the other provider a stream gets the same-model resume only |
124
135
 
125
136
  On by default: it only ever acts where the alternative is a broken stream.
137
+ `dario doctor` reports which hops this host can take:
138
+
139
+ ```
140
+ [ OK ] Continuation on: a dying stream resumes on the same model, then on gpt-5.6-sol → claude-sonnet-5 (two hops)
141
+ [ OK ] Continuation on: a dying stream resumes on the same model only — add --pool-fallback for a second hop on the other subscription
142
+ [INFO] Continuation off — a stream that dies mid-answer ends truncated (unset DARIO_MIDSTREAM_CONTINUE / drop --no-midstream-continue)
143
+ ```
144
+
145
+ ## Seeing it happen
146
+
147
+ Nothing about a healthy stream shows the feature, so there is a tap that
148
+ kills one on purpose:
149
+
150
+ ```bash
151
+ DARIO_CHAOS_CUT_AFTER=300 dario proxy
152
+ ```
153
+
154
+ The first streamed answer dies after 300 characters — the upstream socket is
155
+ cut from dario's side, exactly the failure a real reset produces — and the
156
+ continuation finishes it. Point any client at the proxy, ask for something
157
+ long, and watch the answer keep going past the cut; a raw `curl -N` shows the
158
+ seam comment. `DARIO_CHAOS_CUT_STREAMS=3` cuts the first three instead of one.
159
+ The tap spares resumes, so it shows the first hop — the same model finishing
160
+ its own answer; the other subscription takes over only when that model cannot
161
+ serve the resume. dario warns loudly at startup while the tap is set; it is a
162
+ demo and test affordance, never a default.
126
163
 
127
164
  ## How it was proven
128
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.1.0",
3
+ "version": "6.2.1",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {