@askalf/dario 6.0.53 → 6.1.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 } 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,63 @@ 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
+ // 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;
2958
+ const loopbackHeaders = () => {
2959
+ const h = {};
2960
+ if (apiKey)
2961
+ h['x-api-key'] = apiKey;
2962
+ const c = req.headers[CONSUMER_HEADER];
2963
+ if (typeof c === 'string' && c.length > 0)
2964
+ h[CONSUMER_HEADER] = c;
2965
+ return h;
2966
+ };
2967
+ const parseClientBody = () => {
2968
+ try {
2969
+ const v = JSON.parse(clientBodyBytes.toString('utf-8'));
2970
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null;
2971
+ }
2972
+ catch {
2973
+ return null;
2974
+ }
2975
+ };
2976
+ /**
2977
+ * Where a Claude stream that died mid-way resumes: the codex half of the
2978
+ * `--pool-fallback` chain, exactly the entry a mid-flight 429 would use.
2979
+ * Resolved at failure time — an account that was cooling or missing at
2980
+ * selection may be fine now.
2981
+ */
2982
+ const codexContinuationTarget = async () => {
2983
+ const models = selectPoolFallbackForBody(body);
2984
+ if (models.length === 0)
2985
+ return null;
2986
+ if (!(await hasAnyCodexAccount().catch(() => false)))
2987
+ return null;
2988
+ const stored = await selectCodexAccount().catch(() => null);
2989
+ if (!stored)
2990
+ return null;
2991
+ let creds;
2992
+ try {
2993
+ creds = await getFreshCodexAccount(stored);
2994
+ }
2995
+ catch {
2996
+ return null;
2997
+ }
2998
+ const slugs = await getCodexModelSlugs(creds).catch(() => []);
2999
+ const pick = pickCodexFallback(models, slugs);
3000
+ if (!pick)
3001
+ return null;
3002
+ return {
3003
+ model: `codex:${pick.model}${pick.effort ? `:${pick.effort}` : ''}`,
3004
+ label: `${pick.model} (codex ${creds.alias})`,
3005
+ };
3006
+ };
2931
3007
  // A body that is not a JSON object cannot be routed — every decision
2932
3008
  // below (alias, provider prefix, codex slug, template) peeks at `.model`
2933
3009
  // and each peek swallows its parse error and falls through. So `{` used
@@ -3285,6 +3361,29 @@ export async function startProxy(opts = {}) {
3285
3361
  return;
3286
3362
  }
3287
3363
  const codexReq = requestCount;
3364
+ // A codex stream that dies mid-way resumes on the Claude half of
3365
+ // the chain — the same target a declined codex request defers to.
3366
+ const claudeContinuation = claudeTarget && pool.size > 0 && !upstreamApiKey
3367
+ ? { model: `claude:${claudeTarget.model}${claudeTarget.effort ? `:${claudeTarget.effort}` : ''}`, label: `${claudeTarget.model} (claude pool)` }
3368
+ : null;
3369
+ const codexGuard = midstreamContinue && !isContinuation
3370
+ ? guardFor(res, {
3371
+ shape: isOpenAI ? 'openai' : 'anthropic',
3372
+ write: (chunk) => { if (!res.destroyed)
3373
+ res.write(chunk); },
3374
+ isClientGone: () => res.destroyed || res.writableEnded,
3375
+ requestNo: codexReq,
3376
+ verbose,
3377
+ resume: {
3378
+ clientBody: parseClientBody,
3379
+ loopbackBase,
3380
+ loopbackHeaders: loopbackHeaders(),
3381
+ resolveTarget: async () => claudeContinuation,
3382
+ onBeforeResume: releaseQueueSlot,
3383
+ timeoutMs: upstreamTimeoutMs,
3384
+ },
3385
+ })
3386
+ : null;
3288
3387
  const served = codexAvailable && await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', fetch, canDefer,
3289
3388
  // Before this hook a codex request left no trace: nothing in
3290
3389
  // /analytics, nothing in the request log, no per-account count.
@@ -3330,7 +3429,7 @@ export async function startProxy(opts = {}) {
3330
3429
  // dario#1260 — the effort named by the model-name suffix stripped
3331
3430
  // above. Undefined for every request that did not name one, which
3332
3431
  // leaves the outbound body exactly as it was.
3333
- effortForCodex(requestEffort));
3432
+ effortForCodex(requestEffort), codexGuard);
3334
3433
  if (served) {
3335
3434
  // A provider that just served is not rate-limited.
3336
3435
  providerCooldowns.clear('codex');
@@ -4587,12 +4686,36 @@ export async function startProxy(opts = {}) {
4587
4686
  // false the client's socket buffer is full, so pause the read loop until
4588
4687
  // it drains instead of buffering the whole (fast) upstream in memory.
4589
4688
  let needsDrain = false;
4590
- const writeToClient = (chunk) => {
4689
+ const writeToClientRaw = (chunk) => {
4591
4690
  if (clientDisconnected)
4592
4691
  return;
4593
4692
  if (res.write(chunk) === false)
4594
4693
  needsDrain = true;
4595
4694
  };
4695
+ // Mid-stream continuation (v6.1): every client-bound frame passes
4696
+ // through the guard so a stream that dies with content on the wire can
4697
+ // be finished from the other subscription instead of truncated. Only a
4698
+ // 2xx stream is guarded — an upstream error body is not a message.
4699
+ const guard = midstreamContinue && !isContinuation && upstream.status >= 200 && upstream.status < 300
4700
+ ? new MidstreamGuard({
4701
+ shape: isOpenAI ? 'openai' : 'anthropic',
4702
+ write: writeToClientRaw,
4703
+ end: () => { if (!res.writableEnded)
4704
+ res.end(); },
4705
+ isClientGone: () => clientDisconnected || res.destroyed || upstreamAbortReason === 'client_closed' || upstreamAbortReason === 'sse_overflow',
4706
+ requestNo: requestCount,
4707
+ verbose,
4708
+ resume: {
4709
+ clientBody: parseClientBody,
4710
+ loopbackBase,
4711
+ loopbackHeaders: loopbackHeaders(),
4712
+ resolveTarget: codexContinuationTarget,
4713
+ onBeforeResume: releaseQueueSlot,
4714
+ timeoutMs: upstreamTimeoutMs,
4715
+ },
4716
+ })
4717
+ : null;
4718
+ const writeToClient = guard ? (chunk) => guard.write(chunk) : writeToClientRaw;
4596
4719
  // Resolves on 'close' (vanished client) AND on upstream abort. The
4597
4720
  // abort arm is what keeps a connected-but-not-reading client from
4598
4721
  // parking this handler forever and leaking its queue slot — the
@@ -4712,7 +4835,10 @@ export async function startProxy(opts = {}) {
4712
4835
  if (!upstreamAbort.signal.aborted)
4713
4836
  upstreamAbort.abort();
4714
4837
  }
4715
- res.end();
4838
+ if (guard)
4839
+ await guard.finish();
4840
+ else
4841
+ res.end();
4716
4842
  // Stamp the response-completion timestamp + token count so the
4717
4843
  // next request's think-time delay can model human read time.
4718
4844
  // Only on 2xx — error responses don't represent content the user
@@ -4875,7 +5001,7 @@ export async function startProxy(opts = {}) {
4875
5001
  clearTimeout(upstreamTimeout);
4876
5002
  if (onClientClose !== null)
4877
5003
  req.off('close', onClientClose);
4878
- queue.release(consumerFromHeaders);
5004
+ releaseQueueSlot();
4879
5005
  }
4880
5006
  });
4881
5007
  server.on('error', async (err) => {
@@ -0,0 +1,144 @@
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. 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:
64
+
65
+ ```
66
+ [dario] #42 stream died after 1240 chars — no continuation target (set --pool-fallback with an entry for the other provider)
67
+ ```
68
+
69
+ The request's queue slot is released before the loopback is made, so a
70
+ `--max-concurrent=1` proxy resumes instead of waiting on itself.
71
+
72
+ ## The seam
73
+
74
+ The resume is the client's own request with two turns appended: the partial
75
+ answer as the assistant turn, and a user turn asking for the rest. There is
76
+ **no assistant prefill** — Claude 4.6+ answers a trailing assistant turn with a
77
+ 400, and the Responses API never had the concept — so the resume is
78
+ instruction-driven on both providers.
79
+
80
+ The user turn is written as the person whose connection dropped, not as an
81
+ operator notice. The first live run is why: told `[transport notice] … resume
82
+ now`, claude-sonnet-5 answered that the notice "isn't an actual system message
83
+ — it's just text in your prompt" and stopped, which is the injection-awareness
84
+ it should have. "My connection dropped while you were writing that reply, so I
85
+ only received it up to this point: «…». Please pick up exactly where you left
86
+ off" is an ordinary request and gets the ordinary answer.
87
+
88
+ It asks the model to begin by repeating, verbatim, the last ~40 characters of
89
+ the cut-off text, then continue. dario holds the first ~240
90
+ characters of the resume, finds that repeat with a whitespace- and
91
+ quote-normalized match, cuts it, and streams everything after it. The model
92
+ renders the seam — the space between two words, the four-space indent, the
93
+ second half of a split word — inside its own token stream, and dario only trims.
94
+ Told merely to "continue", a model drops the boundary whitespace often enough
95
+ to notice (`replies<cut>with`); told to repeat the anchor, it does not.
96
+
97
+ One rule on top: prose cut mid-sentence whose continuation opens with a
98
+ paragraph break gets one space instead. Inside a code fence a newline is
99
+ content and is left alone.
100
+
101
+ If the model does not repeat the anchor, the longest exact overlap between the
102
+ partial's tail and the resume's head is trimmed; if there is none, nothing is.
103
+
104
+ ## What it costs
105
+
106
+ One extra request on the other subscription, carrying the whole conversation
107
+ plus the partial. The original request is logged and counted as it was (a
108
+ 502 on the codex path, a truncated 200 on the Claude path); the resume is
109
+ logged as its own request. A resume that fails before producing anything hands
110
+ the stream back to end as it would have. A resume that fails after producing
111
+ something forwards its error frame if it sent one, and otherwise simply stops —
112
+ whatever it had already written is on the wire, and the stream is left without
113
+ its terminal event so the client sees the truncation. A twice-truncated answer
114
+ is never closed with a synthetic `end_turn`; only the resume's own
115
+ `message_stop` / `[DONE]` finishes the message.
116
+
117
+ ## Switches
118
+
119
+ | | |
120
+ |---|---|
121
+ | `--no-midstream-continue` | off for this proxy |
122
+ | `DARIO_MIDSTREAM_CONTINUE=0` | same, for the container |
123
+ | `--pool-fallback=…` | where a stream resumes; no entry for the other provider means no resume |
124
+
125
+ On by default: it only ever acts where the alternative is a broken stream.
126
+
127
+ ## How it was proven
128
+
129
+ `test/midstream-continuation-wiring.mjs` runs a real proxy against a fake
130
+ Anthropic upstream and a codex stub, kills the stream in every way listed
131
+ above, and replays what the client received through a strict grammar check of
132
+ both wire shapes. Before that, the same splice ran outside the proxy against
133
+ production dario 6.0.51 with real Opus 5 and a real ChatGPT Plus account, both
134
+ directions, prose and code: ten runs, zero restarts, zero preamble, zero
135
+ repetition, anchor matched exactly five times out of five, and the official
136
+ `@anthropic-ai/sdk` accepted every spliced stream as one message. The seam,
137
+ verbatim, from one of them:
138
+
139
+ ```
140
+ …concatenation of one or more unit components in the
141
+ order hours, min<CUT>utes, and seconds. At least one component is required.
142
+ ```
143
+
144
+ 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.1.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": {