@askalf/dario 6.2.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/dist/doctor-core.d.ts +15 -0
- package/dist/doctor-core.js +26 -0
- package/dist/midstream.d.ts +28 -0
- package/dist/midstream.js +56 -0
- package/dist/proxy.js +18 -3
- package/docs/midstream-continuation.md +26 -0
- package/package.json +1 -1
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
|
@@ -334,6 +334,34 @@ export declare class MidstreamGuard {
|
|
|
334
334
|
private continueFrom;
|
|
335
335
|
private log;
|
|
336
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;
|
|
337
365
|
/** Convenience for sites that hold a ServerResponse: the guard writes through `write`, ends through `res.end()`. */
|
|
338
366
|
export declare function guardFor(res: ServerResponse, o: Omit<MidstreamGuardOptions, 'end'>): MidstreamGuard;
|
|
339
367
|
/**
|
package/dist/midstream.js
CHANGED
|
@@ -901,6 +901,62 @@ export class MidstreamGuard {
|
|
|
901
901
|
(this.o.log ?? ((l) => console.log(`[dario] ${l}`)))(line);
|
|
902
902
|
}
|
|
903
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
|
+
}
|
|
904
960
|
/** Convenience for sites that hold a ServerResponse: the guard writes through `write`, ends through `res.end()`. */
|
|
905
961
|
export function guardFor(res, o) {
|
|
906
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, MAX_CONTINUATION_DEPTH, continuationDepth } 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');
|
|
@@ -3397,7 +3412,7 @@ export async function startProxy(opts = {}) {
|
|
|
3397
3412
|
},
|
|
3398
3413
|
})
|
|
3399
3414
|
: null;
|
|
3400
|
-
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,
|
|
3401
3416
|
// Before this hook a codex request left no trace: nothing in
|
|
3402
3417
|
// /analytics, nothing in the request log, no per-account count.
|
|
3403
3418
|
// The dock (and anyone reading /analytics) saw a proxy that
|
|
@@ -134,6 +134,32 @@ is never closed with a synthetic `end_turn`; only the resume's own
|
|
|
134
134
|
| `--pool-fallback=…` | where the second hop goes; without an entry for the other provider a stream gets the same-model resume only |
|
|
135
135
|
|
|
136
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.
|
|
137
163
|
|
|
138
164
|
## How it was proven
|
|
139
165
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.2.
|
|
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": {
|