@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.
@@ -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}` });
@@ -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
- 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');
@@ -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', fetch, canDefer,
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.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": {