@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 +1 -1
- package/dist/doctor-core.d.ts +15 -0
- package/dist/doctor-core.js +26 -0
- package/dist/midstream.d.ts +54 -3
- package/dist/midstream.js +127 -23
- package/dist/proxy.js +37 -8
- package/docs/midstream-continuation.md +53 -16
- package/package.json +1 -1
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
|
|
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
|
|
package/dist/doctor-core.d.ts
CHANGED
|
@@ -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;
|
package/dist/doctor-core.js
CHANGED
|
@@ -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}` });
|
package/dist/midstream.d.ts
CHANGED
|
@@ -49,8 +49,22 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import type { ServerResponse } from 'node:http';
|
|
51
51
|
export type WireShape = 'anthropic' | 'openai';
|
|
52
|
-
/**
|
|
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
|
-
/**
|
|
265
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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 (
|
|
733
|
-
this.log(`#${this.o.requestNo} stream died after ${
|
|
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
|
-
|
|
738
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2956
|
-
//
|
|
2957
|
-
|
|
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',
|
|
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.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
The
|
|
54
|
-
a
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
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": {
|