@askalf/dario 6.1.0 → 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.
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
 
@@ -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
  }
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
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, 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';
@@ -2952,9 +2952,21 @@ export async function startProxy(opts = {}) {
2952
2952
  // re-issues the CLIENT's request, not the rewritten one, so dario's own
2953
2953
  // rules apply to the resume the same way they applied to the original.
2954
2954
  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;
2955
+ // How deep in a continuation chain this request sits: 0 for a client
2956
+ // request, 1 for its resume, 2 for the resume of that resume — which is
2957
+ // never continued itself (MAX_CONTINUATION_DEPTH).
2958
+ const requestDepth = continuationDepth(req.headers[CONTINUATION_HEADER]);
2959
+ const isContinuation = requestDepth >= MAX_CONTINUATION_DEPTH;
2960
+ /**
2961
+ * First hop: the SAME model again, through the front door. The pool
2962
+ * picks a seat (sticky binding keeps the prompt cache warm), and if the
2963
+ * provider cannot take it at all the existing pre-byte failover already
2964
+ * hands it to the other one. Null when the client named no model.
2965
+ */
2966
+ const sameModelTarget = () => {
2967
+ const m = parseClientBody()?.model;
2968
+ return typeof m === 'string' && m.length > 0 ? { model: m, label: `${m} (same model)` } : null;
2969
+ };
2958
2970
  const loopbackHeaders = () => {
2959
2971
  const h = {};
2960
2972
  if (apiKey)
@@ -3373,12 +3385,13 @@ export async function startProxy(opts = {}) {
3373
3385
  res.write(chunk); },
3374
3386
  isClientGone: () => res.destroyed || res.writableEnded,
3375
3387
  requestNo: codexReq,
3388
+ depth: requestDepth,
3376
3389
  verbose,
3377
3390
  resume: {
3378
3391
  clientBody: parseClientBody,
3379
3392
  loopbackBase,
3380
3393
  loopbackHeaders: loopbackHeaders(),
3381
- resolveTarget: async () => claudeContinuation,
3394
+ resolveTarget: async (hop) => hop === 1 ? sameModelTarget() : claudeContinuation,
3382
3395
  onBeforeResume: releaseQueueSlot,
3383
3396
  timeoutMs: upstreamTimeoutMs,
3384
3397
  },
@@ -4704,12 +4717,13 @@ export async function startProxy(opts = {}) {
4704
4717
  res.end(); },
4705
4718
  isClientGone: () => clientDisconnected || res.destroyed || upstreamAbortReason === 'client_closed' || upstreamAbortReason === 'sse_overflow',
4706
4719
  requestNo: requestCount,
4720
+ depth: requestDepth,
4707
4721
  verbose,
4708
4722
  resume: {
4709
4723
  clientBody: parseClientBody,
4710
4724
  loopbackBase,
4711
4725
  loopbackHeaders: loopbackHeaders(),
4712
- resolveTarget: codexContinuationTarget,
4726
+ resolveTarget: async (hop) => hop === 1 ? sameModelTarget() : codexContinuationTarget(),
4713
4727
  onBeforeResume: releaseQueueSlot,
4714
4728
  timeoutMs: upstreamTimeoutMs,
4715
4729
  },
@@ -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,7 +131,7 @@ 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.
126
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
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": {