@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/README.md +3 -1
- package/dist/cli.js +19 -1
- package/dist/codex-backend.d.ts +16 -1
- package/dist/codex-backend.js +54 -6
- package/dist/midstream.d.ts +344 -0
- package/dist/midstream.js +920 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +144 -4
- package/docs/midstream-continuation.md +155 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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": {
|