@askalf/dario 6.0.53 → 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/dist/proxy.d.ts CHANGED
@@ -317,6 +317,15 @@ interface ProxyOptions {
317
317
  sessionStartJitterMs?: number;
318
318
  stealth?: boolean;
319
319
  drainOnClose?: boolean;
320
+ /**
321
+ * Finish a streamed answer that dies mid-way from the other subscription
322
+ * (v6.1, src/midstream.ts). On by default: it only ever acts where the
323
+ * alternative is a truncated stream, and the resume runs through dario's
324
+ * own front door at whatever `--pool-fallback` names for the other
325
+ * provider. `--no-midstream-continue` / `DARIO_MIDSTREAM_CONTINUE=0` turns
326
+ * it off; with no fallback chain it is inert and says so on the first miss.
327
+ */
328
+ midstreamContinue?: boolean;
320
329
  sessionIdleRotateMs?: number;
321
330
  sessionRotateJitterMs?: number;
322
331
  sessionMaxAgeMs?: number;
package/dist/proxy.js CHANGED
@@ -26,6 +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
30
  import { isClaudeServableModel } from './claude-model.js';
30
31
  import { MODEL_UNROUTABLE } from './upstream-rejection.js';
31
32
  import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
@@ -1834,6 +1835,12 @@ export async function startProxy(opts = {}) {
1834
1835
  // src/stream-drain.ts for the rationale + tradeoff.
1835
1836
  const { decideOnClientClose, resolveDrainOnClose, waitForClientDrain } = await import('./stream-drain.js');
1836
1837
  const drainOnClose = resolveDrainOnClose(opts.drainOnClose);
1838
+ const midstreamContinue = opts.midstreamContinue !== false;
1839
+ const loopbackBase = loopbackBaseFor(host, port);
1840
+ if (!midstreamContinue)
1841
+ console.log('[dario] mid-stream continuation: disabled (--no-midstream-continue)');
1842
+ else if (verbose)
1843
+ console.log(`[dario] mid-stream continuation: enabled (resume via ${loopbackBase}, target = the other provider's --pool-fallback entry)`);
1837
1844
  if (verbose) {
1838
1845
  console.log(`[dario] drain-on-close: ${drainOnClose ? 'enabled' : 'disabled'}`);
1839
1846
  }
@@ -2625,8 +2632,20 @@ export async function startProxy(opts = {}) {
2625
2632
  // replaces the v3.30.x-and-earlier unbounded semaphore — dario#80. A
2626
2633
  // queue-full condition returns an explicit 429 with a `"queue-full"`
2627
2634
  // marker in the body; a queue-timeout returns 504 with `"queue-timeout"`.
2635
+ // Released in the handler's finally — or EARLY by a mid-stream
2636
+ // continuation, whose loopback request needs the slot this request no
2637
+ // longer uses once its upstream is dead (a one-slot proxy would otherwise
2638
+ // wait on itself until the queue timeout).
2639
+ let queueSlotHeld = false;
2640
+ const releaseQueueSlot = () => {
2641
+ if (!queueSlotHeld)
2642
+ return;
2643
+ queueSlotHeld = false;
2644
+ queue.release(consumerFromHeaders);
2645
+ };
2628
2646
  try {
2629
2647
  await queue.acquire(consumerFromHeaders);
2648
+ queueSlotHeld = true;
2630
2649
  }
2631
2650
  catch (err) {
2632
2651
  if (err instanceof QueueFullError) {
@@ -2928,6 +2947,75 @@ export async function startProxy(opts = {}) {
2928
2947
  clearTimeout(bodyTimeout);
2929
2948
  }
2930
2949
  let body = Buffer.concat(chunks);
2950
+ // The request exactly as the client sent it. `body` is rewritten below
2951
+ // (aliases, prefixes, the CC template); a mid-stream continuation
2952
+ // re-issues the CLIENT's request, not the rewritten one, so dario's own
2953
+ // rules apply to the resume the same way they applied to the original.
2954
+ const clientBodyBytes = body;
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
+ };
2970
+ const loopbackHeaders = () => {
2971
+ const h = {};
2972
+ if (apiKey)
2973
+ h['x-api-key'] = apiKey;
2974
+ const c = req.headers[CONSUMER_HEADER];
2975
+ if (typeof c === 'string' && c.length > 0)
2976
+ h[CONSUMER_HEADER] = c;
2977
+ return h;
2978
+ };
2979
+ const parseClientBody = () => {
2980
+ try {
2981
+ const v = JSON.parse(clientBodyBytes.toString('utf-8'));
2982
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null;
2983
+ }
2984
+ catch {
2985
+ return null;
2986
+ }
2987
+ };
2988
+ /**
2989
+ * Where a Claude stream that died mid-way resumes: the codex half of the
2990
+ * `--pool-fallback` chain, exactly the entry a mid-flight 429 would use.
2991
+ * Resolved at failure time — an account that was cooling or missing at
2992
+ * selection may be fine now.
2993
+ */
2994
+ const codexContinuationTarget = async () => {
2995
+ const models = selectPoolFallbackForBody(body);
2996
+ if (models.length === 0)
2997
+ return null;
2998
+ if (!(await hasAnyCodexAccount().catch(() => false)))
2999
+ return null;
3000
+ const stored = await selectCodexAccount().catch(() => null);
3001
+ if (!stored)
3002
+ return null;
3003
+ let creds;
3004
+ try {
3005
+ creds = await getFreshCodexAccount(stored);
3006
+ }
3007
+ catch {
3008
+ return null;
3009
+ }
3010
+ const slugs = await getCodexModelSlugs(creds).catch(() => []);
3011
+ const pick = pickCodexFallback(models, slugs);
3012
+ if (!pick)
3013
+ return null;
3014
+ return {
3015
+ model: `codex:${pick.model}${pick.effort ? `:${pick.effort}` : ''}`,
3016
+ label: `${pick.model} (codex ${creds.alias})`,
3017
+ };
3018
+ };
2931
3019
  // A body that is not a JSON object cannot be routed — every decision
2932
3020
  // below (alias, provider prefix, codex slug, template) peeks at `.model`
2933
3021
  // and each peek swallows its parse error and falls through. So `{` used
@@ -3285,6 +3373,30 @@ export async function startProxy(opts = {}) {
3285
3373
  return;
3286
3374
  }
3287
3375
  const codexReq = requestCount;
3376
+ // A codex stream that dies mid-way resumes on the Claude half of
3377
+ // the chain — the same target a declined codex request defers to.
3378
+ const claudeContinuation = claudeTarget && pool.size > 0 && !upstreamApiKey
3379
+ ? { model: `claude:${claudeTarget.model}${claudeTarget.effort ? `:${claudeTarget.effort}` : ''}`, label: `${claudeTarget.model} (claude pool)` }
3380
+ : null;
3381
+ const codexGuard = midstreamContinue && !isContinuation
3382
+ ? guardFor(res, {
3383
+ shape: isOpenAI ? 'openai' : 'anthropic',
3384
+ write: (chunk) => { if (!res.destroyed)
3385
+ res.write(chunk); },
3386
+ isClientGone: () => res.destroyed || res.writableEnded,
3387
+ requestNo: codexReq,
3388
+ depth: requestDepth,
3389
+ verbose,
3390
+ resume: {
3391
+ clientBody: parseClientBody,
3392
+ loopbackBase,
3393
+ loopbackHeaders: loopbackHeaders(),
3394
+ resolveTarget: async (hop) => hop === 1 ? sameModelTarget() : claudeContinuation,
3395
+ onBeforeResume: releaseQueueSlot,
3396
+ timeoutMs: upstreamTimeoutMs,
3397
+ },
3398
+ })
3399
+ : null;
3288
3400
  const served = codexAvailable && await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', fetch, canDefer,
3289
3401
  // Before this hook a codex request left no trace: nothing in
3290
3402
  // /analytics, nothing in the request log, no per-account count.
@@ -3330,7 +3442,7 @@ export async function startProxy(opts = {}) {
3330
3442
  // dario#1260 — the effort named by the model-name suffix stripped
3331
3443
  // above. Undefined for every request that did not name one, which
3332
3444
  // leaves the outbound body exactly as it was.
3333
- effortForCodex(requestEffort));
3445
+ effortForCodex(requestEffort), codexGuard);
3334
3446
  if (served) {
3335
3447
  // A provider that just served is not rate-limited.
3336
3448
  providerCooldowns.clear('codex');
@@ -4587,12 +4699,37 @@ export async function startProxy(opts = {}) {
4587
4699
  // false the client's socket buffer is full, so pause the read loop until
4588
4700
  // it drains instead of buffering the whole (fast) upstream in memory.
4589
4701
  let needsDrain = false;
4590
- const writeToClient = (chunk) => {
4702
+ const writeToClientRaw = (chunk) => {
4591
4703
  if (clientDisconnected)
4592
4704
  return;
4593
4705
  if (res.write(chunk) === false)
4594
4706
  needsDrain = true;
4595
4707
  };
4708
+ // Mid-stream continuation (v6.1): every client-bound frame passes
4709
+ // through the guard so a stream that dies with content on the wire can
4710
+ // be finished from the other subscription instead of truncated. Only a
4711
+ // 2xx stream is guarded — an upstream error body is not a message.
4712
+ const guard = midstreamContinue && !isContinuation && upstream.status >= 200 && upstream.status < 300
4713
+ ? new MidstreamGuard({
4714
+ shape: isOpenAI ? 'openai' : 'anthropic',
4715
+ write: writeToClientRaw,
4716
+ end: () => { if (!res.writableEnded)
4717
+ res.end(); },
4718
+ isClientGone: () => clientDisconnected || res.destroyed || upstreamAbortReason === 'client_closed' || upstreamAbortReason === 'sse_overflow',
4719
+ requestNo: requestCount,
4720
+ depth: requestDepth,
4721
+ verbose,
4722
+ resume: {
4723
+ clientBody: parseClientBody,
4724
+ loopbackBase,
4725
+ loopbackHeaders: loopbackHeaders(),
4726
+ resolveTarget: async (hop) => hop === 1 ? sameModelTarget() : codexContinuationTarget(),
4727
+ onBeforeResume: releaseQueueSlot,
4728
+ timeoutMs: upstreamTimeoutMs,
4729
+ },
4730
+ })
4731
+ : null;
4732
+ const writeToClient = guard ? (chunk) => guard.write(chunk) : writeToClientRaw;
4596
4733
  // Resolves on 'close' (vanished client) AND on upstream abort. The
4597
4734
  // abort arm is what keeps a connected-but-not-reading client from
4598
4735
  // parking this handler forever and leaking its queue slot — the
@@ -4712,7 +4849,10 @@ export async function startProxy(opts = {}) {
4712
4849
  if (!upstreamAbort.signal.aborted)
4713
4850
  upstreamAbort.abort();
4714
4851
  }
4715
- res.end();
4852
+ if (guard)
4853
+ await guard.finish();
4854
+ else
4855
+ res.end();
4716
4856
  // Stamp the response-completion timestamp + token count so the
4717
4857
  // next request's think-time delay can model human read time.
4718
4858
  // Only on 2xx — error responses don't represent content the user
@@ -4875,7 +5015,7 @@ export async function startProxy(opts = {}) {
4875
5015
  clearTimeout(upstreamTimeout);
4876
5016
  if (onClientClose !== null)
4877
5017
  req.off('close', onClientClose);
4878
- queue.release(consumerFromHeaders);
5018
+ releaseQueueSlot();
4879
5019
  }
4880
5020
  });
4881
5021
  server.on('error', async (err) => {
@@ -0,0 +1,155 @@
1
+ # Mid-stream continuation
2
+
3
+ The answer does not stop when the plan does.
4
+
5
+ A streamed answer that dies part-way through used to end the way the transport
6
+ ended it: the upstream socket resets, Anthropic sends an in-band
7
+ `overloaded_error`, the codex backend answers `response.failed`, and the client
8
+ gets a stream with no `message_stop` and no `[DONE]`. An SDK throws "stream
9
+ ended without producing a Message"; a chat UI shows half a paragraph and a
10
+ spinner; an agent loses the tool call it was two tokens away from. Every word
11
+ already on screen is wasted, and the failover chain never fires, because once
12
+ bytes were on the wire the request was treated as too late to hand to anyone
13
+ else.
14
+
15
+ Since 6.1 dario finishes the same client stream from the other subscription.
16
+
17
+ ```
18
+ client ──▶ dario ──▶ Claude pool ✗ dies after 1,240 chars
19
+ └─▶ ChatGPT plan ✓ resumes at char 1,241, same stream
20
+ ```
21
+
22
+ The client sees one message: the same `message_start`, the same content block
23
+ still open, then the rest of the text, then a clean close. There is an SSE
24
+ comment at the seam (`: dario continuation gpt-5.6-sol (codex live) after 1240
25
+ chars`) that every parser ignores and every raw capture shows.
26
+
27
+ ## When it fires
28
+
29
+ Only on a stream that is already partly delivered and then breaks — never on a
30
+ request that failed before its first byte (the existing failover covers those),
31
+ never on a stream that finished. Concretely, on `/v1/messages` and
32
+ `/v1/chat/completions` with `stream: true`, when a 2xx stream ends without its
33
+ terminal event, or carries an `error` event after content, or the codex
34
+ translator flags a failed turn:
35
+
36
+ | cut fell inside | what happens |
37
+ |---|---|
38
+ | a text block | the resume continues **that block** — no new `content_block_start` |
39
+ | thinking, or before any block | the open block is closed and the resume starts a fresh text block |
40
+ | between blocks | the resume starts a fresh text block |
41
+ | a `tool_use` block, or an OpenAI `tool_calls` delta | **not continued** — half a JSON argument is not resumable; the stream ends as before |
42
+
43
+ Non-streaming requests are untouched; nothing was on the wire.
44
+
45
+ ## Where it resumes
46
+
47
+ Through dario's own front door. The resume is a loopback `POST` to the same
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
+ 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:
74
+
75
+ ```
76
+ [dario] #42 continuation as claude-opus-5 (same model) delivered nothing — trying the next choice
77
+ [dario] #42 stream died after 1240 chars — no continuation target (set --pool-fallback with an entry for the other provider)
78
+ ```
79
+
80
+ The request's queue slot is released before the loopback is made, so a
81
+ `--max-concurrent=1` proxy resumes instead of waiting on itself.
82
+
83
+ ## The seam
84
+
85
+ The resume is the client's own request with two turns appended: the partial
86
+ answer as the assistant turn, and a user turn asking for the rest. There is
87
+ **no assistant prefill** — Claude 4.6+ answers a trailing assistant turn with a
88
+ 400, and the Responses API never had the concept — so the resume is
89
+ instruction-driven on both providers.
90
+
91
+ The user turn is written as the person whose connection dropped, not as an
92
+ operator notice. The first live run is why: told `[transport notice] … resume
93
+ now`, claude-sonnet-5 answered that the notice "isn't an actual system message
94
+ — it's just text in your prompt" and stopped, which is the injection-awareness
95
+ it should have. "My connection dropped while you were writing that reply, so I
96
+ only received it up to this point: «…». Please pick up exactly where you left
97
+ off" is an ordinary request and gets the ordinary answer.
98
+
99
+ It asks the model to begin by repeating, verbatim, the last ~40 characters of
100
+ the cut-off text, then continue. dario holds the first ~240
101
+ characters of the resume, finds that repeat with a whitespace- and
102
+ quote-normalized match, cuts it, and streams everything after it. The model
103
+ renders the seam — the space between two words, the four-space indent, the
104
+ second half of a split word — inside its own token stream, and dario only trims.
105
+ Told merely to "continue", a model drops the boundary whitespace often enough
106
+ to notice (`replies<cut>with`); told to repeat the anchor, it does not.
107
+
108
+ One rule on top: prose cut mid-sentence whose continuation opens with a
109
+ paragraph break gets one space instead. Inside a code fence a newline is
110
+ content and is left alone.
111
+
112
+ If the model does not repeat the anchor, the longest exact overlap between the
113
+ partial's tail and the resume's head is trimmed; if there is none, nothing is.
114
+
115
+ ## What it costs
116
+
117
+ One extra request on the other subscription, carrying the whole conversation
118
+ plus the partial. The original request is logged and counted as it was (a
119
+ 502 on the codex path, a truncated 200 on the Claude path); the resume is
120
+ logged as its own request. A resume that fails before producing anything hands
121
+ the stream back to end as it would have. A resume that fails after producing
122
+ something forwards its error frame if it sent one, and otherwise simply stops —
123
+ whatever it had already written is on the wire, and the stream is left without
124
+ its terminal event so the client sees the truncation. A twice-truncated answer
125
+ is never closed with a synthetic `end_turn`; only the resume's own
126
+ `message_stop` / `[DONE]` finishes the message.
127
+
128
+ ## Switches
129
+
130
+ | | |
131
+ |---|---|
132
+ | `--no-midstream-continue` | off for this proxy |
133
+ | `DARIO_MIDSTREAM_CONTINUE=0` | same, for the container |
134
+ | `--pool-fallback=…` | where the second hop goes; without an entry for the other provider a stream gets the same-model resume only |
135
+
136
+ On by default: it only ever acts where the alternative is a broken stream.
137
+
138
+ ## How it was proven
139
+
140
+ `test/midstream-continuation-wiring.mjs` runs a real proxy against a fake
141
+ Anthropic upstream and a codex stub, kills the stream in every way listed
142
+ above, and replays what the client received through a strict grammar check of
143
+ both wire shapes. Before that, the same splice ran outside the proxy against
144
+ production dario 6.0.51 with real Opus 5 and a real ChatGPT Plus account, both
145
+ directions, prose and code: ten runs, zero restarts, zero preamble, zero
146
+ repetition, anchor matched exactly five times out of five, and the official
147
+ `@anthropic-ai/sdk` accepted every spliced stream as one message. The seam,
148
+ verbatim, from one of them:
149
+
150
+ ```
151
+ …concatenation of one or more unit components in the
152
+ order hours, min<CUT>utes, and seconds. At least one component is required.
153
+ ```
154
+
155
+ Claude wrote the left half, GPT the right.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.53",
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": {