@askalf/dario 6.14.3 → 6.16.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/body-identity.d.ts +25 -0
- package/dist/body-identity.js +68 -0
- package/dist/cc-template.js +3 -14
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +35 -7
- package/dist/codex-backend.js +12 -4
- package/dist/config-file.d.ts +1 -1
- package/dist/config-file.js +2 -2
- package/dist/midstream.js +6 -2
- package/dist/openai-backend.js +8 -3
- package/dist/pool.d.ts +18 -1
- package/dist/pool.js +31 -10
- package/dist/proxy.d.ts +11 -1
- package/dist/proxy.js +157 -29
- package/dist/relaunch-signals.d.ts +35 -0
- package/dist/relaunch-signals.js +41 -0
- package/dist/stream-drain.d.ts +4 -3
- package/dist/stream-drain.js +4 -3
- package/dist/tui/tabs/config.js +2 -2
- package/dist/upstream-deadline.d.ts +25 -0
- package/dist/upstream-deadline.js +76 -0
- package/docs/commands.md +1 -1
- package/docs/configuration.md +1 -0
- package/docs/multi-account-pool.md +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The three values `metadata.user_id` is built from. */
|
|
2
|
+
export interface BodyIdentity {
|
|
3
|
+
deviceId: string;
|
|
4
|
+
accountUuid: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The `metadata.user_id` string Claude Code sends: a JSON object with
|
|
9
|
+
* device_id, account_uuid and session_id in that order. Shared by the
|
|
10
|
+
* template build and the failover rebind so both produce the same bytes.
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildMetadataUserId(identity: BodyIdentity): string;
|
|
13
|
+
/**
|
|
14
|
+
* `body` with `metadata.user_id` rebuilt for `identity`, for a request that
|
|
15
|
+
* fails over to another account. `cchVersion` is the Claude Code version to
|
|
16
|
+
* stamp cch for after the rewrite, or null where the first serialize did not
|
|
17
|
+
* stamp (DARIO_CCH=random); stampCch is itself a no-op without a seed.
|
|
18
|
+
*
|
|
19
|
+
* Returns `body` itself, the same Buffer, when there is nothing to rebind: no
|
|
20
|
+
* body, a body that is not JSON, no string `metadata.user_id`, or a user_id
|
|
21
|
+
* that already names `identity`. The body was serialized by JSON.stringify,
|
|
22
|
+
* so parse and stringify again give the same bytes apart from user_id, and
|
|
23
|
+
* user_id is replaced in place so key order is kept.
|
|
24
|
+
*/
|
|
25
|
+
export declare function rebindBodyIdentity(body: Buffer | undefined, identity: BodyIdentity, cchVersion: string | null): Buffer | undefined;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account identity a built Claude Code body carries, and how it moves to
|
|
3
|
+
* another seat when a request fails over mid-flight.
|
|
4
|
+
*
|
|
5
|
+
* The template build stamps `metadata.user_id` from the selected account's
|
|
6
|
+
* device_id and account_uuid plus the session id the
|
|
7
|
+
* `x-claude-code-session-id` header carries. A 429 or 401/403 failover sends
|
|
8
|
+
* the same body to a different account, so the body must name that account
|
|
9
|
+
* and the session id its header now carries; otherwise seat B is sent seat
|
|
10
|
+
* A's device_id and account_uuid and a session_id that disagrees with its own
|
|
11
|
+
* header. The cch stamp (src/cch.ts) hashes the body, user_id included, so a
|
|
12
|
+
* rewritten body is stamped again.
|
|
13
|
+
*
|
|
14
|
+
* Pure, and imports nothing that reads the filesystem, so it is unit-testable
|
|
15
|
+
* without a proxy or a ~/.dario.
|
|
16
|
+
*/
|
|
17
|
+
import { stampCch } from './cch.js';
|
|
18
|
+
/**
|
|
19
|
+
* The `metadata.user_id` string Claude Code sends: a JSON object with
|
|
20
|
+
* device_id, account_uuid and session_id in that order. Shared by the
|
|
21
|
+
* template build and the failover rebind so both produce the same bytes.
|
|
22
|
+
*/
|
|
23
|
+
export function buildMetadataUserId(identity) {
|
|
24
|
+
return JSON.stringify({
|
|
25
|
+
device_id: identity.deviceId,
|
|
26
|
+
account_uuid: identity.accountUuid,
|
|
27
|
+
session_id: identity.sessionId,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `body` with `metadata.user_id` rebuilt for `identity`, for a request that
|
|
32
|
+
* fails over to another account. `cchVersion` is the Claude Code version to
|
|
33
|
+
* stamp cch for after the rewrite, or null where the first serialize did not
|
|
34
|
+
* stamp (DARIO_CCH=random); stampCch is itself a no-op without a seed.
|
|
35
|
+
*
|
|
36
|
+
* Returns `body` itself, the same Buffer, when there is nothing to rebind: no
|
|
37
|
+
* body, a body that is not JSON, no string `metadata.user_id`, or a user_id
|
|
38
|
+
* that already names `identity`. The body was serialized by JSON.stringify,
|
|
39
|
+
* so parse and stringify again give the same bytes apart from user_id, and
|
|
40
|
+
* user_id is replaced in place so key order is kept.
|
|
41
|
+
*/
|
|
42
|
+
export function rebindBodyIdentity(body, identity, cchVersion) {
|
|
43
|
+
if (!body)
|
|
44
|
+
return body;
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(body.toString('utf8'));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
53
|
+
return body;
|
|
54
|
+
const metadata = parsed.metadata;
|
|
55
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata))
|
|
56
|
+
return body;
|
|
57
|
+
const meta = metadata;
|
|
58
|
+
if (typeof meta.user_id !== 'string')
|
|
59
|
+
return body;
|
|
60
|
+
const userId = buildMetadataUserId(identity);
|
|
61
|
+
if (meta.user_id === userId)
|
|
62
|
+
return body;
|
|
63
|
+
meta.user_id = userId;
|
|
64
|
+
let text = JSON.stringify(parsed);
|
|
65
|
+
if (cchVersion !== null)
|
|
66
|
+
text = stampCch(text, cchVersion);
|
|
67
|
+
return Buffer.from(text);
|
|
68
|
+
}
|
package/dist/cc-template.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { loadTemplate, promptVariantsOf, VARIANT_FAMILIES, PLATFORM_ONLY_TOOLS, INTERACTIVE_ONLY_TOOLS, CONFIG_SCOPED_TOOLS, DEFERRED_TOOLS, } from './live-fingerprint.js';
|
|
11
11
|
import { VALID_EFFORT_VALUES, parseEffortSuffix } from './effort.js';
|
|
12
|
+
import { buildMetadataUserId } from './body-identity.js';
|
|
12
13
|
// Re-exported so existing importers (scripts/capture-and-bake.mjs, the template
|
|
13
14
|
// invariant tests) keep their import site. The definitions live in
|
|
14
15
|
// live-fingerprint.ts because loadTemplate must apply the same superset rule to
|
|
@@ -1819,13 +1820,7 @@ export function buildCCRequest(clientBody, billingTag, cacheControl, identity, o
|
|
|
1819
1820
|
}
|
|
1820
1821
|
}
|
|
1821
1822
|
const body = { ...clientBody, system };
|
|
1822
|
-
body.metadata = {
|
|
1823
|
-
user_id: JSON.stringify({
|
|
1824
|
-
device_id: identity.deviceId,
|
|
1825
|
-
account_uuid: identity.accountUuid,
|
|
1826
|
-
session_id: identity.sessionId,
|
|
1827
|
-
}),
|
|
1828
|
-
};
|
|
1823
|
+
body.metadata = { user_id: buildMetadataUserId(identity) };
|
|
1829
1824
|
return { body, toolMap: new Map(), unmappedTools: [], genuineCC: true };
|
|
1830
1825
|
}
|
|
1831
1826
|
// ── Detect text-tool-protocol clients up-front ──
|
|
@@ -2275,13 +2270,7 @@ export function buildCCRequest(clientBody, billingTag, cacheControl, identity, o
|
|
|
2275
2270
|
ccRequest.tool_choice = passed;
|
|
2276
2271
|
}
|
|
2277
2272
|
// Metadata
|
|
2278
|
-
ccRequest.metadata = {
|
|
2279
|
-
user_id: JSON.stringify({
|
|
2280
|
-
device_id: identity.deviceId,
|
|
2281
|
-
account_uuid: identity.accountUuid,
|
|
2282
|
-
session_id: identity.sessionId,
|
|
2283
|
-
}),
|
|
2284
|
-
};
|
|
2273
|
+
ccRequest.metadata = { user_id: buildMetadataUserId(identity) };
|
|
2285
2274
|
ccRequest.max_tokens = resolveMaxTokens(opts.maxTokens, clientBody);
|
|
2286
2275
|
// Model-specific fields — order: thinking, context_management, output_config
|
|
2287
2276
|
//
|
package/dist/cli.d.ts
CHANGED
|
@@ -114,6 +114,13 @@ export declare function parseBooleanEnv(value: string | undefined): boolean | un
|
|
|
114
114
|
* Set(['thinking','env']) — value "thinking,env" → preserve listed
|
|
115
115
|
*/
|
|
116
116
|
export declare function resolvePreserveOrchestrationTags(args: string[], env: string | undefined): Set<string> | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* --upstream-timeout / DARIO_UPSTREAM_TIMEOUT_MS. The flag wins over the env
|
|
119
|
+
* var even when it is 0, and 0 then resolves to undefined so the proxy's
|
|
120
|
+
* default applies: an explicit `--upstream-timeout=0` keeps the default
|
|
121
|
+
* rather than falling through to the env value. Exported for tests.
|
|
122
|
+
*/
|
|
123
|
+
export declare function resolveUpstreamTimeoutMs(flag: number | undefined, env: string | undefined): number | undefined;
|
|
117
124
|
/**
|
|
118
125
|
* The seat shape a running proxy returns from `/accounts`.
|
|
119
126
|
*
|
package/dist/cli.js
CHANGED
|
@@ -516,6 +516,12 @@ async function proxy() {
|
|
|
516
516
|
// used to sever every streaming response after five seconds. dario#1370.
|
|
517
517
|
const shutdownGraceMs = parsePositiveIntFlag('--shutdown-grace=')
|
|
518
518
|
?? parsePositiveIntEnv(process.env['DARIO_SHUTDOWN_GRACE_MS']);
|
|
519
|
+
// --upstream-timeout=MS: how long dario waits for upstream (default 300000).
|
|
520
|
+
// Until the response streams it caps the whole wait; once a 2xx streams it
|
|
521
|
+
// is the longest gap allowed between two chunks, so a long answer that is
|
|
522
|
+
// still sending is never cut. 0 keeps the default: an always-firing timer
|
|
523
|
+
// would fail every request.
|
|
524
|
+
const upstreamTimeoutMs = resolveUpstreamTimeoutMs(parsePositiveIntFlag('--upstream-timeout='), process.env['DARIO_UPSTREAM_TIMEOUT_MS']);
|
|
519
525
|
// --pool-strategy=headroom|fill-first — where UNBOUND (new) conversations
|
|
520
526
|
// land. `headroom` (default) spreads them to the seat with the most slack;
|
|
521
527
|
// `fill-first` concentrates them on the alphabetically-first eligible seat
|
|
@@ -526,9 +532,9 @@ async function proxy() {
|
|
|
526
532
|
// expires soonest before touching the rest.
|
|
527
533
|
const poolStrategyFromFlag = args.find((a) => a.startsWith('--pool-strategy='))?.split('=')[1];
|
|
528
534
|
if (poolStrategyFromFlag !== undefined
|
|
529
|
-
&& poolStrategyFromFlag !== 'headroom' && poolStrategyFromFlag !== '
|
|
530
|
-
&& poolStrategyFromFlag !== 'expiring-first') {
|
|
531
|
-
console.error(`[dario] Invalid --pool-strategy "${poolStrategyFromFlag}". Must be headroom, fill-first or expiring-first.`);
|
|
535
|
+
&& poolStrategyFromFlag !== 'headroom' && poolStrategyFromFlag !== 'headroom-by-plan'
|
|
536
|
+
&& poolStrategyFromFlag !== 'fill-first' && poolStrategyFromFlag !== 'expiring-first') {
|
|
537
|
+
console.error(`[dario] Invalid --pool-strategy "${poolStrategyFromFlag}". Must be headroom, headroom-by-plan, fill-first or expiring-first.`);
|
|
532
538
|
process.exit(1);
|
|
533
539
|
}
|
|
534
540
|
const poolStrategy = poolStrategyFromFlag
|
|
@@ -786,7 +792,7 @@ async function proxy() {
|
|
|
786
792
|
console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
|
|
787
793
|
process.exit(1);
|
|
788
794
|
}
|
|
789
|
-
await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, shutdownGraceMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, familyFallback, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath, keySockets: keySockets.length > 0 ? keySockets : undefined });
|
|
795
|
+
await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, shutdownGraceMs, upstreamTimeoutMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, familyFallback, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath, keySockets: keySockets.length > 0 ? keySockets : undefined });
|
|
790
796
|
}
|
|
791
797
|
/**
|
|
792
798
|
* `dario keys` — named keys for a shared dario (v6.8, dario#1318). One
|
|
@@ -1208,6 +1214,16 @@ function parsePreserveTagsValue(value) {
|
|
|
1208
1214
|
return new Set(['*']);
|
|
1209
1215
|
return new Set(trimmed.split(',').map(s => s.trim()).filter(Boolean));
|
|
1210
1216
|
}
|
|
1217
|
+
/**
|
|
1218
|
+
* --upstream-timeout / DARIO_UPSTREAM_TIMEOUT_MS. The flag wins over the env
|
|
1219
|
+
* var even when it is 0, and 0 then resolves to undefined so the proxy's
|
|
1220
|
+
* default applies: an explicit `--upstream-timeout=0` keeps the default
|
|
1221
|
+
* rather than falling through to the env value. Exported for tests.
|
|
1222
|
+
*/
|
|
1223
|
+
export function resolveUpstreamTimeoutMs(flag, env) {
|
|
1224
|
+
const ms = flag ?? parsePositiveIntEnv(env);
|
|
1225
|
+
return ms === 0 ? undefined : ms;
|
|
1226
|
+
}
|
|
1211
1227
|
function parsePositiveIntFlag(prefix) {
|
|
1212
1228
|
const found = args.find(a => a.startsWith(prefix));
|
|
1213
1229
|
if (!found)
|
|
@@ -2212,6 +2228,12 @@ async function help() {
|
|
|
2212
2228
|
container's stop grace above it, or Docker
|
|
2213
2229
|
SIGKILLs the drain. Env:
|
|
2214
2230
|
DARIO_SHUTDOWN_GRACE_MS. (dario#1370)
|
|
2231
|
+
--upstream-timeout=MS How long dario waits on upstream (default:
|
|
2232
|
+
300000). Until the response streams it caps
|
|
2233
|
+
the whole wait; while a 2xx streams it is
|
|
2234
|
+
the longest gap between two chunks, so a
|
|
2235
|
+
live stream is never cut. 0 keeps the
|
|
2236
|
+
default. Env: DARIO_UPSTREAM_TIMEOUT_MS.
|
|
2215
2237
|
--max-concurrent=N Max in-flight requests across the WHOLE proxy,
|
|
2216
2238
|
not per seat (default: 10; a pool defaults to
|
|
2217
2239
|
10 per seat). Past it, requests wait in dario.
|
|
@@ -2239,11 +2261,13 @@ async function help() {
|
|
|
2239
2261
|
dario returns 504 "queue-timeout"
|
|
2240
2262
|
(default: 60000).
|
|
2241
2263
|
Env: DARIO_QUEUE_TIMEOUT_MS. (dario#80)
|
|
2242
|
-
--pool-strategy=<headroom|fill-first|expiring-first>
|
|
2264
|
+
--pool-strategy=<headroom|headroom-by-plan|fill-first|expiring-first>
|
|
2243
2265
|
Where new conversations land in a multi-
|
|
2244
2266
|
account pool. headroom (default) spreads
|
|
2245
2267
|
them to the seat with the most slack;
|
|
2246
|
-
|
|
2268
|
+
headroom-by-plan (opt-in) counts each
|
|
2269
|
+
seat's slack by plan size (Max Nx = N,
|
|
2270
|
+
Pro = 1); fill-first concentrates them on the
|
|
2247
2271
|
alphabetically-first eligible seat until
|
|
2248
2272
|
it drains to the floor, then spills;
|
|
2249
2273
|
expiring-first does the same in order of
|
|
@@ -3174,12 +3198,16 @@ if (isDirectEntry) {
|
|
|
3174
3198
|
if (!('Bun' in globalThis) && !process.env.DARIO_NO_BUN) {
|
|
3175
3199
|
try {
|
|
3176
3200
|
const { execFileSync, spawn } = await import('node:child_process');
|
|
3201
|
+
const { forwardSignals, relaunchExitCode } = await import('./relaunch-signals.js');
|
|
3177
3202
|
execFileSync('bun', ['--version'], { stdio: 'ignore', timeout: 3000 });
|
|
3178
3203
|
const child = spawn('bun', ['run', ...process.argv.slice(1)], {
|
|
3179
3204
|
stdio: 'inherit',
|
|
3180
3205
|
env: { ...process.env, DARIO_NO_BUN: '1' },
|
|
3181
3206
|
});
|
|
3182
|
-
child
|
|
3207
|
+
// The child owns the graceful shutdown; as PID 1 in a container this
|
|
3208
|
+
// process would otherwise drop SIGTERM and docker stop would SIGKILL both.
|
|
3209
|
+
forwardSignals(child);
|
|
3210
|
+
child.on('exit', (code, signal) => process.exit(relaunchExitCode(code, signal)));
|
|
3183
3211
|
// Prevent this process from continuing
|
|
3184
3212
|
await new Promise(() => { });
|
|
3185
3213
|
}
|
package/dist/codex-backend.js
CHANGED
|
@@ -29,6 +29,7 @@ import { resolveClaudeTarget } from './claude-model.js';
|
|
|
29
29
|
import { parseEffortSuffix } from './effort.js';
|
|
30
30
|
import { BAKED_BASE_MODELS } from './model-catalog.js';
|
|
31
31
|
import { parseRetryAfterMs } from './provider-cooldown.js';
|
|
32
|
+
import { upstreamDeadline } from './upstream-deadline.js';
|
|
32
33
|
import { noteCodexUsageHeaders, parseCodexUsageEndpoint, recordCodexUsage, codexUsageFor } from './codex-usage.js';
|
|
33
34
|
export const CODEX_BACKEND_BASE_URL = process.env.DARIO_CODEX_BASE_URL || 'https://chatgpt.com/backend-api/codex';
|
|
34
35
|
/** The usage endpoint the Codex CLI's /status reads, beside the Responses base. */
|
|
@@ -914,7 +915,8 @@ timing) {
|
|
|
914
915
|
abort.abort();
|
|
915
916
|
} };
|
|
916
917
|
res.on('close', onClientClose);
|
|
917
|
-
|
|
918
|
+
// Caps the wait until headers, then the gap between chunks of the 2xx body.
|
|
919
|
+
const timeout = upstreamDeadline(upstreamTimeoutMs, () => abort.abort());
|
|
918
920
|
let usage = null;
|
|
919
921
|
try {
|
|
920
922
|
if (verbose)
|
|
@@ -962,6 +964,7 @@ timing) {
|
|
|
962
964
|
return true;
|
|
963
965
|
}
|
|
964
966
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': corsOrigin, ...securityHeaders, ...splitHeaders() });
|
|
967
|
+
timeout.streaming();
|
|
965
968
|
const reader = upstream.body.getReader();
|
|
966
969
|
const decoder = new TextDecoder();
|
|
967
970
|
let tail = '';
|
|
@@ -969,6 +972,7 @@ timing) {
|
|
|
969
972
|
try {
|
|
970
973
|
while (true) {
|
|
971
974
|
const { done, value } = await reader.read();
|
|
975
|
+
timeout.touch();
|
|
972
976
|
if (done)
|
|
973
977
|
break;
|
|
974
978
|
if (clientGone)
|
|
@@ -1033,7 +1037,7 @@ timing) {
|
|
|
1033
1037
|
return true;
|
|
1034
1038
|
}
|
|
1035
1039
|
finally {
|
|
1036
|
-
|
|
1040
|
+
timeout.clear();
|
|
1037
1041
|
res.removeListener('close', onClientClose);
|
|
1038
1042
|
}
|
|
1039
1043
|
}
|
|
@@ -1196,7 +1200,9 @@ timing) {
|
|
|
1196
1200
|
// Usage seen so far, so a stream the client abandoned still reports what the
|
|
1197
1201
|
// subscription already spent. Populated once the translators exist.
|
|
1198
1202
|
let usageSoFar = () => null;
|
|
1199
|
-
|
|
1203
|
+
// Caps the wait until headers, then the gap between chunks of the 2xx body:
|
|
1204
|
+
// the backend always streams, so a long answer is never cut mid-way.
|
|
1205
|
+
const timeout = upstreamDeadline(upstreamTimeoutMs, () => abort.abort());
|
|
1200
1206
|
try {
|
|
1201
1207
|
if (verbose)
|
|
1202
1208
|
console.log(`[dario] → codex backend: ${target} (model: ${model})`);
|
|
@@ -1324,11 +1330,13 @@ timing) {
|
|
|
1324
1330
|
}
|
|
1325
1331
|
let buffered = '';
|
|
1326
1332
|
if (upstream.body) {
|
|
1333
|
+
timeout.streaming();
|
|
1327
1334
|
const reader = upstream.body.getReader();
|
|
1328
1335
|
const decoder = new TextDecoder();
|
|
1329
1336
|
try {
|
|
1330
1337
|
while (true) {
|
|
1331
1338
|
const { done, value } = await reader.read();
|
|
1339
|
+
timeout.touch();
|
|
1332
1340
|
if (done)
|
|
1333
1341
|
break;
|
|
1334
1342
|
// The abort above normally makes this read reject; a stream that
|
|
@@ -1484,7 +1492,7 @@ timing) {
|
|
|
1484
1492
|
return true;
|
|
1485
1493
|
}
|
|
1486
1494
|
finally {
|
|
1487
|
-
|
|
1495
|
+
timeout.clear();
|
|
1488
1496
|
res.removeListener('close', onClientClose);
|
|
1489
1497
|
}
|
|
1490
1498
|
}
|
package/dist/config-file.d.ts
CHANGED
|
@@ -89,7 +89,7 @@ export interface DarioConfig {
|
|
|
89
89
|
* `expiring-first` is fill-first ordered by each seat's 7-day reset,
|
|
90
90
|
* soonest first.
|
|
91
91
|
*/
|
|
92
|
-
strategy?: 'headroom' | 'fill-first' | 'expiring-first';
|
|
92
|
+
strategy?: 'headroom' | 'headroom-by-plan' | 'fill-first' | 'expiring-first';
|
|
93
93
|
/** Headroom at/below which a seat counts as drained — ratio 0.02..0.5 (dario#1333). */
|
|
94
94
|
headroomFloor?: number;
|
|
95
95
|
};
|
package/dist/config-file.js
CHANGED
|
@@ -309,8 +309,8 @@ function sanitize(parsed) {
|
|
|
309
309
|
}
|
|
310
310
|
if (isPlainObject(parsed.pool)) {
|
|
311
311
|
out.pool = {};
|
|
312
|
-
if (parsed.pool.strategy === 'headroom' || parsed.pool.strategy === '
|
|
313
|
-
|| parsed.pool.strategy === 'expiring-first') {
|
|
312
|
+
if (parsed.pool.strategy === 'headroom' || parsed.pool.strategy === 'headroom-by-plan'
|
|
313
|
+
|| parsed.pool.strategy === 'fill-first' || parsed.pool.strategy === 'expiring-first') {
|
|
314
314
|
out.pool.strategy = parsed.pool.strategy;
|
|
315
315
|
}
|
|
316
316
|
if (typeof parsed.pool.headroomFloor === 'number' && Number.isFinite(parsed.pool.headroomFloor)) {
|
package/dist/midstream.js
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* the existing pre-byte failover covers them), and the api-key OpenAI backend.
|
|
48
48
|
* A stream that cannot be continued ends exactly as it did before this module.
|
|
49
49
|
*/
|
|
50
|
+
import { upstreamDeadline } from './upstream-deadline.js';
|
|
50
51
|
/**
|
|
51
52
|
* Marks a loopback request as a continuation and carries its DEPTH: `1` for
|
|
52
53
|
* the resume of a client request, `2` for the resume of that resume. A
|
|
@@ -836,7 +837,8 @@ export class MidstreamGuard {
|
|
|
836
837
|
const body = buildResumeBody(this.o.shape, clientBody, target.model, partial);
|
|
837
838
|
const splicer = new Splicer(this.o.shape, s, partial);
|
|
838
839
|
const abort = new AbortController();
|
|
839
|
-
|
|
840
|
+
// Caps the wait until headers, then the gap between chunks of the resume.
|
|
841
|
+
const timer = upstreamDeadline(r.timeoutMs, () => abort.abort());
|
|
840
842
|
const startedAt = Date.now();
|
|
841
843
|
try {
|
|
842
844
|
r.onBeforeResume?.();
|
|
@@ -854,6 +856,7 @@ export class MidstreamGuard {
|
|
|
854
856
|
// An SSE comment, ignored by every parser, so a raw capture shows where
|
|
855
857
|
// the second provider took over.
|
|
856
858
|
this.o.write(`${SEAM_COMMENT_PREFIX} ${target.label} after ${partial.length} chars\n\n`);
|
|
859
|
+
timer.streaming();
|
|
857
860
|
const reader = res.body.getReader();
|
|
858
861
|
const split = new SseFrameSplitter();
|
|
859
862
|
let sawContent = false;
|
|
@@ -861,6 +864,7 @@ export class MidstreamGuard {
|
|
|
861
864
|
try {
|
|
862
865
|
outer: while (true) {
|
|
863
866
|
const { done, value } = await reader.read();
|
|
867
|
+
timer.touch();
|
|
864
868
|
if (done)
|
|
865
869
|
break;
|
|
866
870
|
if (this.o.isClientGone()) {
|
|
@@ -919,7 +923,7 @@ export class MidstreamGuard {
|
|
|
919
923
|
return 'failed';
|
|
920
924
|
}
|
|
921
925
|
finally {
|
|
922
|
-
|
|
926
|
+
timer.clear();
|
|
923
927
|
}
|
|
924
928
|
}
|
|
925
929
|
log(line) {
|
package/dist/openai-backend.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { readFile, writeFile, mkdir, unlink, readdir } from 'node:fs/promises';
|
|
16
16
|
import { join, basename } from 'node:path';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
|
+
import { upstreamDeadline } from './upstream-deadline.js';
|
|
18
19
|
const DARIO_DIR = join(homedir(), '.dario');
|
|
19
20
|
const BACKENDS_DIR = join(DARIO_DIR, 'backends');
|
|
20
21
|
export const MINIMAX_MODELS = ['MiniMax-M3.1-Flash', 'MiniMax-M3'];
|
|
@@ -138,7 +139,8 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
|
|
|
138
139
|
// Drop Anthropic-specific headers entirely
|
|
139
140
|
void clientBeta;
|
|
140
141
|
const abort = new AbortController();
|
|
141
|
-
|
|
142
|
+
// Caps the wait until headers, then the gap between chunks of a 2xx body.
|
|
143
|
+
const timeout = upstreamDeadline(upstreamTimeoutMs, () => abort.abort());
|
|
142
144
|
try {
|
|
143
145
|
if (verbose) {
|
|
144
146
|
console.log(`[dario] → openai backend: ${target}`);
|
|
@@ -165,10 +167,13 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
|
|
|
165
167
|
}
|
|
166
168
|
res.writeHead(upstream.status, respHeaders);
|
|
167
169
|
if (upstream.body) {
|
|
170
|
+
if (upstream.ok)
|
|
171
|
+
timeout.streaming();
|
|
168
172
|
const reader = upstream.body.getReader();
|
|
169
173
|
try {
|
|
170
174
|
while (true) {
|
|
171
175
|
const { done, value } = await reader.read();
|
|
176
|
+
timeout.touch();
|
|
172
177
|
if (done)
|
|
173
178
|
break;
|
|
174
179
|
if (value)
|
|
@@ -182,7 +187,7 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
|
|
|
182
187
|
res.end();
|
|
183
188
|
}
|
|
184
189
|
catch (err) {
|
|
185
|
-
|
|
190
|
+
timeout.clear();
|
|
186
191
|
// Log error details server-side only. Responding with err.message
|
|
187
192
|
// exposes internal stack / path / module information (CodeQL
|
|
188
193
|
// js/stack-trace-exposure). The client gets a generic 502.
|
|
@@ -205,6 +210,6 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
|
|
|
205
210
|
return;
|
|
206
211
|
}
|
|
207
212
|
finally {
|
|
208
|
-
|
|
213
|
+
timeout.clear();
|
|
209
214
|
}
|
|
210
215
|
}
|
package/dist/pool.d.ts
CHANGED
|
@@ -396,8 +396,24 @@ export interface PoolStatus {
|
|
|
396
396
|
* order is the operator's knob — name seats `1-main` / `2-overflow` to
|
|
397
397
|
* pick the fill order. Sticky bindings behave identically in both modes;
|
|
398
398
|
* strategy only decides where UNBOUND (new) conversations land.
|
|
399
|
+
*
|
|
400
|
+
* `headroom-by-plan` (opt-in): headroom, with each seat's headroom counted
|
|
401
|
+
* by its plan size (see planWeight). Never the default: utilization is a
|
|
402
|
+
* ratio of an account-wide budget that also carries the owner's own use, no
|
|
403
|
+
* absolute capacity reaches the wire, and the window that binds (5h or 7d)
|
|
404
|
+
* matters more than the plan name. A smaller plan regularly has more real
|
|
405
|
+
* room than a bigger one. It is for operators who know their seats are
|
|
406
|
+
* otherwise idle and want the bigger plan to carry proportionally more.
|
|
407
|
+
*/
|
|
408
|
+
export type PoolStrategy = 'headroom' | 'headroom-by-plan' | 'fill-first' | 'expiring-first';
|
|
409
|
+
/**
|
|
410
|
+
* How much a seat's headroom counts under `headroom-by-plan`: N for a Max Nx
|
|
411
|
+
* plan (from the profile's `rate_limit_tier`, e.g. `default_claude_max_20x`),
|
|
412
|
+
* 1 for Pro, and 1 for anything unrecognised or unknown, so a seat whose plan
|
|
413
|
+
* dario cannot read is never favoured. Only the whole `default_claude_max_Nx`
|
|
414
|
+
* tier name counts; a tier that merely contains `max_Nx` is unrecognised.
|
|
399
415
|
*/
|
|
400
|
-
export
|
|
416
|
+
export declare function planWeight(rateLimitTier: string | undefined): number;
|
|
401
417
|
/**
|
|
402
418
|
* `expiring-first` — fill-first, ordered by WHEN each seat's capacity expires
|
|
403
419
|
* instead of by alias: new conversations fill the seat whose 7-day window
|
|
@@ -602,6 +618,7 @@ export declare function headroomFloorProblem(value: string | number | null | und
|
|
|
602
618
|
*/
|
|
603
619
|
export declare function headroomFloorFlagProblem(value: string): string;
|
|
604
620
|
export declare function resolvePoolHeadroomFloor(explicit?: string | number | null, env?: NodeJS.ProcessEnv): number;
|
|
621
|
+
export declare function pickMaxHeadroom(accounts: PoolAccount[], family?: string | null, byPlan?: boolean): PoolAccount;
|
|
605
622
|
/**
|
|
606
623
|
* The 7-day reset `expiring-first` orders by, in epoch ms: the seat's own
|
|
607
624
|
* reading while it is still ahead of `now`, else +Infinity (never read, or the
|
package/dist/pool.js
CHANGED
|
@@ -408,6 +408,18 @@ export function accountAction(account, now = Date.now()) {
|
|
|
408
408
|
return 'wait';
|
|
409
409
|
return 'none';
|
|
410
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* How much a seat's headroom counts under `headroom-by-plan`: N for a Max Nx
|
|
413
|
+
* plan (from the profile's `rate_limit_tier`, e.g. `default_claude_max_20x`),
|
|
414
|
+
* 1 for Pro, and 1 for anything unrecognised or unknown, so a seat whose plan
|
|
415
|
+
* dario cannot read is never favoured. Only the whole `default_claude_max_Nx`
|
|
416
|
+
* tier name counts; a tier that merely contains `max_Nx` is unrecognised.
|
|
417
|
+
*/
|
|
418
|
+
export function planWeight(rateLimitTier) {
|
|
419
|
+
const m = /^default_claude_max_(\d+)x$/i.exec(rateLimitTier ?? '');
|
|
420
|
+
const n = m ? Number(m[1]) : 1;
|
|
421
|
+
return Number.isFinite(n) && n >= 1 ? n : 1;
|
|
422
|
+
}
|
|
411
423
|
/**
|
|
412
424
|
* `expiring-first` — fill-first, ordered by WHEN each seat's capacity expires
|
|
413
425
|
* instead of by alias: new conversations fill the seat whose 7-day window
|
|
@@ -431,7 +443,7 @@ export function resolvePoolStrategy(explicit, env = process.env) {
|
|
|
431
443
|
if (typeof c !== 'string')
|
|
432
444
|
continue;
|
|
433
445
|
const s = c.trim().toLowerCase();
|
|
434
|
-
if (s === 'headroom' || s === 'fill-first' || s === 'expiring-first')
|
|
446
|
+
if (s === 'headroom' || s === 'headroom-by-plan' || s === 'fill-first' || s === 'expiring-first')
|
|
435
447
|
return s;
|
|
436
448
|
}
|
|
437
449
|
return 'headroom';
|
|
@@ -446,6 +458,8 @@ export function resolvePoolStrategy(explicit, env = process.env) {
|
|
|
446
458
|
export function describePoolStrategy(strategy, floor = DEFAULT_POOL_HEADROOM_FLOOR) {
|
|
447
459
|
const pct = `${Math.round(floor * 100)}% floor`;
|
|
448
460
|
switch (strategy) {
|
|
461
|
+
case 'headroom-by-plan':
|
|
462
|
+
return `headroom-by-plan: new conversations go to the seat with the most headroom counted by plan size (Max Nx counts N, Pro 1); ${pct}`;
|
|
449
463
|
case 'fill-first':
|
|
450
464
|
return `fill-first — new conversations fill the alphabetically-first seat until the ${pct}, then spill`;
|
|
451
465
|
case 'expiring-first':
|
|
@@ -866,14 +880,21 @@ export function resolvePoolHeadroomFloor(explicit, env = process.env) {
|
|
|
866
880
|
// Pick the account with the most headroom in a single pass. The prior
|
|
867
881
|
// `.reduce()` form recomputed the incumbent's headroom every iteration
|
|
868
882
|
// (~2n computeHeadroom calls); this computes each once (#642-audit).
|
|
869
|
-
|
|
883
|
+
// `byPlan` scales positive headroom by planWeight; zero and negative headroom
|
|
884
|
+
// stay unscaled, so a bigger plan that is over its limit never ranks lower
|
|
885
|
+
// than a smaller one that is over by the same amount.
|
|
886
|
+
export function pickMaxHeadroom(accounts, family, byPlan = false) {
|
|
887
|
+
const score = (a) => {
|
|
888
|
+
const h = computeHeadroom(a.rateLimit, family);
|
|
889
|
+
return byPlan && h > 0 ? h * planWeight(a.rateLimitTier) : h;
|
|
890
|
+
};
|
|
870
891
|
let best = accounts[0];
|
|
871
|
-
let
|
|
892
|
+
let bestScore = score(best);
|
|
872
893
|
for (let i = 1; i < accounts.length; i++) {
|
|
873
|
-
const
|
|
874
|
-
if (
|
|
894
|
+
const s = score(accounts[i]);
|
|
895
|
+
if (s > bestScore) {
|
|
875
896
|
best = accounts[i];
|
|
876
|
-
|
|
897
|
+
bestScore = s;
|
|
877
898
|
}
|
|
878
899
|
}
|
|
879
900
|
return best;
|
|
@@ -1107,10 +1128,10 @@ export class AccountPool {
|
|
|
1107
1128
|
return first;
|
|
1108
1129
|
}
|
|
1109
1130
|
if (!this.hardHeadroomFloor)
|
|
1110
|
-
return pickMaxHeadroom(eligible, family);
|
|
1131
|
+
return pickMaxHeadroom(eligible, family, this.strategy === 'headroom-by-plan');
|
|
1111
1132
|
const aboveFloor = eligible.filter(a => isAboveHeadroomFloor(computeHeadroom(a.rateLimit, family, now), this.headroomFloor));
|
|
1112
1133
|
return aboveFloor.length > 0
|
|
1113
|
-
? pickMaxHeadroom(aboveFloor, family)
|
|
1134
|
+
? pickMaxHeadroom(aboveFloor, family, this.strategy === 'headroom-by-plan')
|
|
1114
1135
|
: pickFloorProbe(eligible, family, this.headroomFloor, now);
|
|
1115
1136
|
}
|
|
1116
1137
|
// No seat is eligible. A seat parked inside a live window is not
|
|
@@ -1287,10 +1308,10 @@ export class AccountPool {
|
|
|
1287
1308
|
return first;
|
|
1288
1309
|
}
|
|
1289
1310
|
if (!this.hardHeadroomFloor)
|
|
1290
|
-
return pickMaxHeadroom(eligible, family);
|
|
1311
|
+
return pickMaxHeadroom(eligible, family, this.strategy === 'headroom-by-plan');
|
|
1291
1312
|
const aboveFloor = eligible.filter(a => isAboveHeadroomFloor(computeHeadroom(a.rateLimit, family, now), this.headroomFloor));
|
|
1292
1313
|
return aboveFloor.length > 0
|
|
1293
|
-
? pickMaxHeadroom(aboveFloor, family)
|
|
1314
|
+
? pickMaxHeadroom(aboveFloor, family, this.strategy === 'headroom-by-plan')
|
|
1294
1315
|
: pickFloorProbe(eligible, family, this.headroomFloor, now);
|
|
1295
1316
|
}
|
|
1296
1317
|
// Mid-flight: the seats a 429 could still hand this request to. A seat
|
package/dist/proxy.d.ts
CHANGED
|
@@ -126,6 +126,13 @@ export declare function parseProviderPrefix(model: string): {
|
|
|
126
126
|
* Exported for tests.
|
|
127
127
|
*/
|
|
128
128
|
export declare const FABLE_FALLBACK_CREDIT_BETA = "fallback-credit-2026-06-01";
|
|
129
|
+
/**
|
|
130
|
+
* Marks the request the family fallback sends again through dario's own front
|
|
131
|
+
* door after a mid-flight 429 (see familyFallbackOn429 in startProxy). A
|
|
132
|
+
* request carrying it is never sent again itself, so the fallback takes one
|
|
133
|
+
* hop. A client that sends it only turns that re-send off for its request.
|
|
134
|
+
*/
|
|
135
|
+
export declare const FAMILY_FALLBACK_LEG_HEADER = "x-dario-family-fallback-leg";
|
|
129
136
|
export declare const CONTEXT_1M_BETA = "context-1m-2025-08-07";
|
|
130
137
|
export declare const MID_CONVERSATION_SYSTEM_BETA = "mid-conversation-system-2026-04-07";
|
|
131
138
|
export declare const EFFORT_BETA = "effort-2025-11-24";
|
|
@@ -496,7 +503,10 @@ interface ProxyOptions {
|
|
|
496
503
|
maxConcurrentPerConsumer?: number;
|
|
497
504
|
/**
|
|
498
505
|
* Max ms before the upstream fetch is aborted. Default 300000 (5 min,
|
|
499
|
-
* matching the Anthropic SDK).
|
|
506
|
+
* matching the Anthropic SDK). It caps the wait until the response
|
|
507
|
+
* streams; once a 2xx body is being read it is the longest allowed gap
|
|
508
|
+
* between two chunks, so a live stream is never cut. `--upstream-timeout`
|
|
509
|
+
* / `DARIO_UPSTREAM_TIMEOUT_MS`. Injectable so tests can exercise the
|
|
500
510
|
* timeout → slot-release path without waiting 5 minutes (dario#905).
|
|
501
511
|
*/
|
|
502
512
|
upstreamTimeoutMs?: number;
|
package/dist/proxy.js
CHANGED
|
@@ -13,6 +13,7 @@ import { getServingProbe } from './serving-probe.js';
|
|
|
13
13
|
import { darioVersion } from './version.js';
|
|
14
14
|
import { CC_TOOL_DEFINITIONS_UNADVERTISABLE, CC_TEMPLATE_PROMPT_BYTES, resolveMaxTokens, buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, UnsupportedClientToolError, CLIENT_TOOL_MCP_PREFIX, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
|
|
15
15
|
import { stampCch, hasCchSeed } from './cch.js';
|
|
16
|
+
import { rebindBodyIdentity } from './body-identity.js';
|
|
16
17
|
import { foldTiming, timingHeaders, timingLogFields } from './timing.js';
|
|
17
18
|
import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
|
|
18
19
|
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, activeParkedBuckets, reconcilePoolAccounts, resolvePoolStrategy, resolvePoolHeadroomFloor, parsePoolHeadroomFloor, describePoolStrategy, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail } from './pool.js';
|
|
@@ -59,6 +60,7 @@ import { parseFamilyFallback, familyFallbackModel, describeFamilyFallback } from
|
|
|
59
60
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS, resolveMaxConcurrent } from './request-queue.js';
|
|
60
61
|
import { waitForIdle, drainThenClose, shutdownTimers, DEFAULT_SHUTDOWN_GRACE_MS } from './shutdown-drain.js';
|
|
61
62
|
import { clampTimerMs } from './timer-ms.js';
|
|
63
|
+
import { upstreamDeadline } from './upstream-deadline.js';
|
|
62
64
|
import { redactSecrets } from './redact.js';
|
|
63
65
|
import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, retryModelCatalogNow, isSuspendedModel } from './model-catalog.js';
|
|
64
66
|
import { classifyUpstreamRejection, diagnosticSnippet, POOL_PARKED } from './upstream-rejection.js';
|
|
@@ -66,7 +68,9 @@ import { ProviderCooldowns, canAttempt, allProvidersCooled, cooldownRetryAfterMs
|
|
|
66
68
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
67
69
|
const DEFAULT_PORT = 3456;
|
|
68
70
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — generous for large prompts, prevents abuse
|
|
69
|
-
|
|
71
|
+
// Until the response streams: the whole wait. While a 2xx streams: the longest
|
|
72
|
+
// gap between two chunks (src/upstream-deadline.ts). 5 min, as in the Anthropic SDK.
|
|
73
|
+
const UPSTREAM_TIMEOUT_MS = 300_000;
|
|
70
74
|
const BODY_READ_TIMEOUT_MS = 30_000; // 30s — prevents slow-loris on body reads
|
|
71
75
|
const DEFAULT_HOST = '127.0.0.1';
|
|
72
76
|
// A host is "loopback" if it's one of the well-known localhost literals.
|
|
@@ -497,6 +501,13 @@ function filterBillableBetas(betas) {
|
|
|
497
501
|
* Exported for tests.
|
|
498
502
|
*/
|
|
499
503
|
export const FABLE_FALLBACK_CREDIT_BETA = 'fallback-credit-2026-06-01';
|
|
504
|
+
/**
|
|
505
|
+
* Marks the request the family fallback sends again through dario's own front
|
|
506
|
+
* door after a mid-flight 429 (see familyFallbackOn429 in startProxy). A
|
|
507
|
+
* request carrying it is never sent again itself, so the fallback takes one
|
|
508
|
+
* hop. A client that sends it only turns that re-send off for its request.
|
|
509
|
+
*/
|
|
510
|
+
export const FAMILY_FALLBACK_LEG_HEADER = 'x-dario-family-fallback-leg';
|
|
500
511
|
export const CONTEXT_1M_BETA = 'context-1m-2025-08-07';
|
|
501
512
|
export const MID_CONVERSATION_SYSTEM_BETA = 'mid-conversation-system-2026-04-07';
|
|
502
513
|
export const EFFORT_BETA = 'effort-2025-11-24';
|
|
@@ -3646,6 +3657,100 @@ export async function startProxy(opts = {}) {
|
|
|
3646
3657
|
return null;
|
|
3647
3658
|
}
|
|
3648
3659
|
};
|
|
3660
|
+
/**
|
|
3661
|
+
* Family fallback, mid-flight (family-fallback.ts). The 429 that just
|
|
3662
|
+
* came back can be the one that parks the family: the seat's included
|
|
3663
|
+
* Fable credit ran out on this request, or dario restarted and has not
|
|
3664
|
+
* yet seen the credit spent (the pool's readings live in memory). The
|
|
3665
|
+
* fallback runs before selection, so this request missed it, and would
|
|
3666
|
+
* go on to the codex fallback or back to the client as a 429.
|
|
3667
|
+
*
|
|
3668
|
+
* Instead it is sent again, as the client wrote it, through dario's own
|
|
3669
|
+
* front door. That request meets the family parked, so the fallback
|
|
3670
|
+
* re-points it and the whole Claude path builds it for the target model,
|
|
3671
|
+
* with that model's template, betas and effort. Rewriting the model on
|
|
3672
|
+
* the body already built for Fable would send Opus a Fable request.
|
|
3673
|
+
*
|
|
3674
|
+
* One hop: the re-sent request carries FAMILY_FALLBACK_LEG_HEADER and is
|
|
3675
|
+
* never sent again itself. Skipped wherever the fallback before selection
|
|
3676
|
+
* is skipped, for a continuation leg, and for a request already re-pointed.
|
|
3677
|
+
* The queue slot is released first, as a continuation does, so a
|
|
3678
|
+
* one-slot proxy does not wait on itself. True when the client was answered.
|
|
3679
|
+
*/
|
|
3680
|
+
const familyFallbackOn429 = async (rejected) => {
|
|
3681
|
+
if (familyFallback.size === 0 || upstreamApiKey || pinnedAccount || modelOverride || fastModelOverride)
|
|
3682
|
+
return false;
|
|
3683
|
+
if (isResponses || isCountTokens || requestDepth > 0 || req.headers[FAMILY_FALLBACK_LEG_HEADER] !== undefined)
|
|
3684
|
+
return false;
|
|
3685
|
+
if (res.headersSent || res.getHeader('x-dario-family-fallback') !== undefined)
|
|
3686
|
+
return false;
|
|
3687
|
+
const fromModel = parseClientBody()?.model;
|
|
3688
|
+
if (typeof fromModel !== 'string')
|
|
3689
|
+
return false;
|
|
3690
|
+
const now = Date.now();
|
|
3691
|
+
const toModel = familyFallbackModel(familyFallback, fromModel, {
|
|
3692
|
+
parked: (f) => pool.familyRateLimited(f, now),
|
|
3693
|
+
servable: (f) => pool.canServe(f, now),
|
|
3694
|
+
});
|
|
3695
|
+
if (!toModel)
|
|
3696
|
+
return false;
|
|
3697
|
+
void rejected.body?.cancel().catch(() => { });
|
|
3698
|
+
console.log(`[dario] #${requestCount} the 429 parked every seat for ${fromModel} -> sent again as ${toModel} (family fallback)`);
|
|
3699
|
+
releaseQueueSlot();
|
|
3700
|
+
const abort = new AbortController();
|
|
3701
|
+
const onClose = () => { if (!res.writableEnded)
|
|
3702
|
+
abort.abort(); };
|
|
3703
|
+
res.on('close', onClose);
|
|
3704
|
+
// The loopback leg is timed like the request it replaces: until
|
|
3705
|
+
// headers, then by the gap between chunks of a 2xx body.
|
|
3706
|
+
const timer = upstreamDeadline(upstreamTimeoutMs, () => abort.abort());
|
|
3707
|
+
try {
|
|
3708
|
+
const leg = await fetch(`${loopbackBase}${isOpenAI ? '/v1/chat/completions' : '/v1/messages'}`, {
|
|
3709
|
+
method: 'POST',
|
|
3710
|
+
headers: { 'content-type': 'application/json', [FAMILY_FALLBACK_LEG_HEADER]: '1', ...loopbackHeaders() },
|
|
3711
|
+
body: new Uint8Array(clientBodyBytes),
|
|
3712
|
+
signal: abort.signal,
|
|
3713
|
+
});
|
|
3714
|
+
const responseHeaders = { 'Access-Control-Allow-Origin': corsOrigin, ...SECURITY_HEADERS };
|
|
3715
|
+
for (const [key, value] of leg.headers.entries()) {
|
|
3716
|
+
if (key === 'content-type' || key === 'retry-after' || key === 'request-id' || key.startsWith('anthropic-ratelimit') || key.startsWith('x-dario-')) {
|
|
3717
|
+
responseHeaders[key] = value;
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
responseHeaders['x-dario-family-fallback'] = fromModel;
|
|
3721
|
+
res.writeHead(leg.status, responseHeaders);
|
|
3722
|
+
if (leg.body) {
|
|
3723
|
+
if (leg.ok)
|
|
3724
|
+
timer.streaming();
|
|
3725
|
+
const reader = leg.body.getReader();
|
|
3726
|
+
while (true) {
|
|
3727
|
+
const { done, value } = await reader.read();
|
|
3728
|
+
timer.touch();
|
|
3729
|
+
if (done)
|
|
3730
|
+
break;
|
|
3731
|
+
if (!res.write(value))
|
|
3732
|
+
await new Promise((resolve) => { res.once('drain', resolve); res.once('close', resolve); });
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
if (!res.writableEnded)
|
|
3736
|
+
res.end();
|
|
3737
|
+
}
|
|
3738
|
+
catch (err) {
|
|
3739
|
+
console.error(`[dario] #${requestCount} family fallback: the re-sent request failed: ${sanitizeError(err)}`);
|
|
3740
|
+
if (!res.headersSent) {
|
|
3741
|
+
res.writeHead(502, JSON_HEADERS);
|
|
3742
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: 'family fallback: the re-sent request failed' } }));
|
|
3743
|
+
}
|
|
3744
|
+
else if (!res.writableEnded) {
|
|
3745
|
+
res.end();
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
finally {
|
|
3749
|
+
timer.clear();
|
|
3750
|
+
res.off('close', onClose);
|
|
3751
|
+
}
|
|
3752
|
+
return true;
|
|
3753
|
+
};
|
|
3649
3754
|
/**
|
|
3650
3755
|
* Where a Claude stream that died mid-way resumes: the codex half of the
|
|
3651
3756
|
* `--pool-fallback` chain, exactly the entry a mid-flight 429 would use.
|
|
@@ -4535,6 +4640,10 @@ export async function startProxy(opts = {}) {
|
|
|
4535
4640
|
// Outbound session id resolved once — either inside the template build
|
|
4536
4641
|
// (so body metadata matches) or below for passthrough (no body build).
|
|
4537
4642
|
let preBodySessionId;
|
|
4643
|
+
// True once the template build has written dario's metadata.user_id
|
|
4644
|
+
// into finalBody. Only such a body names the selected account, so only
|
|
4645
|
+
// it is rebound when a failover switches accounts (see switchAccount).
|
|
4646
|
+
let bodyIdentityOwned = false;
|
|
4538
4647
|
// Request context for hybrid-mode field injection (#33). Built once
|
|
4539
4648
|
// per request from incoming headers so the reverse mapper can fill
|
|
4540
4649
|
// client-declared fields like `sessionId` that CC's schema doesn't
|
|
@@ -4780,6 +4889,7 @@ export async function startProxy(opts = {}) {
|
|
|
4780
4889
|
for (const key of Object.keys(r))
|
|
4781
4890
|
delete r[key];
|
|
4782
4891
|
Object.assign(r, ccBody);
|
|
4892
|
+
bodyIdentityOwned = true;
|
|
4783
4893
|
// `X[1m]` is a client-side LABEL, never a valid wire id — real CC
|
|
4784
4894
|
// sends the BASE model id plus the `context-1m-2025-08-07` beta
|
|
4785
4895
|
// (live capture 2026-06-09, CC v2.1.170 with --model
|
|
@@ -5005,17 +5115,19 @@ export async function startProxy(opts = {}) {
|
|
|
5005
5115
|
// upstream fetch so Anthropic stops generating (and billing) a
|
|
5006
5116
|
// response nobody will read. With `--drain-on-close` set, we
|
|
5007
5117
|
// instead keep the reader spinning to consume the full SSE — see
|
|
5008
|
-
// src/stream-drain.ts for the fingerprint rationale. The
|
|
5009
|
-
//
|
|
5010
|
-
//
|
|
5118
|
+
// src/stream-drain.ts for the fingerprint rationale. The upstream
|
|
5119
|
+
// timeout shares the same controller, so a hung upstream still gets
|
|
5120
|
+
// cut off regardless of drain mode: it caps the wait until headers,
|
|
5121
|
+
// then the gap between chunks once a 2xx streams.
|
|
5011
5122
|
const upstreamAbort = new AbortController();
|
|
5012
5123
|
let clientDisconnected = false;
|
|
5013
|
-
|
|
5124
|
+
const deadline = upstreamDeadline(upstreamTimeoutMs, () => {
|
|
5014
5125
|
if (!upstreamAbort.signal.aborted) {
|
|
5015
5126
|
upstreamAbortReason = 'timeout';
|
|
5016
5127
|
upstreamAbort.abort();
|
|
5017
5128
|
}
|
|
5018
|
-
}
|
|
5129
|
+
});
|
|
5130
|
+
upstreamTimeout = deadline;
|
|
5019
5131
|
onClientClose = () => {
|
|
5020
5132
|
const action = decideOnClientClose(res.writableEnded, upstreamAbort.signal.aborted, drainOnClose);
|
|
5021
5133
|
if (action === 'abort') {
|
|
@@ -5061,6 +5173,25 @@ export async function startProxy(opts = {}) {
|
|
|
5061
5173
|
// combination with headroom -- opus-4-1 needs 2.
|
|
5062
5174
|
const MAX_RECOVERY_PASSES = 4;
|
|
5063
5175
|
let recoveryPasses = 0;
|
|
5176
|
+
// Move the request to a peer seat: auth, session header, the body's
|
|
5177
|
+
// metadata.user_id and the sticky binding all name `next`. The session
|
|
5178
|
+
// id is resolved once so the header and the body carry the same value
|
|
5179
|
+
// (the same rule as the first build), and the body's device_id and
|
|
5180
|
+
// account_uuid are next's, not the rejected seat's. A body dario did not
|
|
5181
|
+
// build (passthrough, count_tokens) is the client's and is left alone.
|
|
5182
|
+
const switchAccount = (next) => {
|
|
5183
|
+
triedAliases.add(next.alias);
|
|
5184
|
+
poolAccount = next;
|
|
5185
|
+
accessToken = next.accessToken;
|
|
5186
|
+
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
5187
|
+
const sessionId = resolveOutboundSession(next, clientSessionKey).sessionId;
|
|
5188
|
+
headers['x-claude-code-session-id'] = sessionId;
|
|
5189
|
+
if (bodyIdentityOwned) {
|
|
5190
|
+
finalBody = rebindBodyIdentity(finalBody, { deviceId: next.identity.deviceId, accountUuid: next.identity.accountUuid, sessionId }, process.env.DARIO_CCH !== 'random' ? cliVersion : null);
|
|
5191
|
+
}
|
|
5192
|
+
pool.rebindSticky(stickyKey, next.alias);
|
|
5193
|
+
poolSync?.bindSticky(stickyKey, next.alias);
|
|
5194
|
+
};
|
|
5064
5195
|
dispatchLoop: while (true) {
|
|
5065
5196
|
// Reorder outbound headers to match CC's captured header sequence
|
|
5066
5197
|
// when the live template recorded one. No-op on bundled-only installs.
|
|
@@ -5400,13 +5531,7 @@ export async function startProxy(opts = {}) {
|
|
|
5400
5531
|
if (poolAccount) {
|
|
5401
5532
|
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
5402
5533
|
if (nextAccount) {
|
|
5403
|
-
|
|
5404
|
-
poolAccount = nextAccount;
|
|
5405
|
-
accessToken = nextAccount.accessToken;
|
|
5406
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
5407
|
-
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
5408
|
-
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
5409
|
-
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
5534
|
+
switchAccount(nextAccount);
|
|
5410
5535
|
peekedBody = null;
|
|
5411
5536
|
continue dispatchLoop;
|
|
5412
5537
|
}
|
|
@@ -5424,6 +5549,8 @@ export async function startProxy(opts = {}) {
|
|
|
5424
5549
|
// The Claude half is out of quota for this request AND for the next
|
|
5425
5550
|
// one — cool it here, honouring the upstream `retry-after` when it
|
|
5426
5551
|
// sent one, so the following request does not re-walk the chain.
|
|
5552
|
+
if (await familyFallbackOn429(upstream))
|
|
5553
|
+
return;
|
|
5427
5554
|
attemptedProviders.add('claude');
|
|
5428
5555
|
providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
|
|
5429
5556
|
if (!pinnedAccount && await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt })) {
|
|
@@ -5502,13 +5629,7 @@ export async function startProxy(opts = {}) {
|
|
|
5502
5629
|
}
|
|
5503
5630
|
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
5504
5631
|
if (nextAccount) {
|
|
5505
|
-
|
|
5506
|
-
poolAccount = nextAccount;
|
|
5507
|
-
accessToken = nextAccount.accessToken;
|
|
5508
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
5509
|
-
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
5510
|
-
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
5511
|
-
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
5632
|
+
switchAccount(nextAccount);
|
|
5512
5633
|
continue dispatchLoop;
|
|
5513
5634
|
}
|
|
5514
5635
|
// No peer available — forward the saved generic-403 bytes when the
|
|
@@ -5536,13 +5657,7 @@ export async function startProxy(opts = {}) {
|
|
|
5536
5657
|
if (poolAccount) {
|
|
5537
5658
|
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
5538
5659
|
if (nextAccount) {
|
|
5539
|
-
|
|
5540
|
-
poolAccount = nextAccount;
|
|
5541
|
-
accessToken = nextAccount.accessToken;
|
|
5542
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
5543
|
-
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
5544
|
-
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
5545
|
-
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
5660
|
+
switchAccount(nextAccount);
|
|
5546
5661
|
continue dispatchLoop;
|
|
5547
5662
|
}
|
|
5548
5663
|
}
|
|
@@ -5552,6 +5667,8 @@ export async function startProxy(opts = {}) {
|
|
|
5552
5667
|
// into body. See attemptPoolFallbackOn429's doc comment for why this
|
|
5553
5668
|
// is one shared function rather than an inline copy.
|
|
5554
5669
|
// Same bookkeeping as the other mid-flight site — see there.
|
|
5670
|
+
if (await familyFallbackOn429(upstream))
|
|
5671
|
+
return;
|
|
5555
5672
|
attemptedProviders.add('claude');
|
|
5556
5673
|
providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
|
|
5557
5674
|
if (!pinnedAccount && await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt })) {
|
|
@@ -5739,11 +5856,17 @@ export async function startProxy(opts = {}) {
|
|
|
5739
5856
|
// parking this handler forever and leaking its queue slot — the
|
|
5740
5857
|
// dario#905 wedge. See waitForClientDrain in src/stream-drain.ts.
|
|
5741
5858
|
const waitForDrain = () => waitForClientDrain(res, upstreamAbort.signal);
|
|
5859
|
+
// A 2xx stream is timed by the gap between chunks from here, so a
|
|
5860
|
+
// long answer is not cut while it still streams. An error body keeps
|
|
5861
|
+
// the headers-phase cap.
|
|
5862
|
+
if (upstream.status >= 200 && upstream.status < 300)
|
|
5863
|
+
deadline.streaming();
|
|
5742
5864
|
try {
|
|
5743
5865
|
let buffer = '';
|
|
5744
5866
|
const MAX_LINE_LENGTH = 1_000_000; // 1MB max per SSE line
|
|
5745
5867
|
while (true) {
|
|
5746
5868
|
const { done, value } = await reader.read();
|
|
5869
|
+
deadline.touch();
|
|
5747
5870
|
if (done) {
|
|
5748
5871
|
upstreamDoneAt = Date.now();
|
|
5749
5872
|
break;
|
|
@@ -6028,7 +6151,7 @@ export async function startProxy(opts = {}) {
|
|
|
6028
6151
|
// setup happens after the body-read phase, so on fast-path errors
|
|
6029
6152
|
// (413, body read timeout) these may still be null — guard accordingly.
|
|
6030
6153
|
if (upstreamTimeout !== null)
|
|
6031
|
-
|
|
6154
|
+
upstreamTimeout.clear();
|
|
6032
6155
|
if (onClientClose !== null)
|
|
6033
6156
|
req.off('close', onClientClose);
|
|
6034
6157
|
releaseQueueSlot();
|
|
@@ -6321,8 +6444,13 @@ export async function startProxy(opts = {}) {
|
|
|
6321
6444
|
catch { /* best-effort flush — never block shutdown on it */ }
|
|
6322
6445
|
}));
|
|
6323
6446
|
};
|
|
6324
|
-
// Graceful shutdown
|
|
6447
|
+
// Graceful shutdown. Runs once: a Ctrl+C in a terminal reaches the Bun child
|
|
6448
|
+
// directly and again through the relaunch wrapper's forwarding.
|
|
6449
|
+
let shuttingDown = false;
|
|
6325
6450
|
const shutdown = () => {
|
|
6451
|
+
if (shuttingDown)
|
|
6452
|
+
return;
|
|
6453
|
+
shuttingDown = true;
|
|
6326
6454
|
console.log('\n[dario] Shutting down...');
|
|
6327
6455
|
clearInterval(presenceInterval);
|
|
6328
6456
|
clearInterval(refreshInterval);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal hand-off for the Bun relaunch.
|
|
3
|
+
*
|
|
4
|
+
* Under Node, the CLI relaunches itself under Bun as a child and parks (cli.ts). The
|
|
5
|
+
* child owns the proxy and its graceful shutdown (drain, ledger and key flush), but a
|
|
6
|
+
* SIGTERM sent to the wrapper never reached it. In a container the wrapper is PID 1,
|
|
7
|
+
* and the kernel drops a signal PID 1 has no handler for, so `docker stop` waited out
|
|
8
|
+
* its whole grace period and then SIGKILLed both processes: every recreate took the
|
|
9
|
+
* full stop timeout and the shutdown flush never ran.
|
|
10
|
+
*
|
|
11
|
+
* `forwardSignals` passes SIGTERM and SIGINT on to the child, and `relaunchExitCode`
|
|
12
|
+
* turns the child's exit into the wrapper's. Both take their process and child as
|
|
13
|
+
* arguments, so they are tested without spawning anything.
|
|
14
|
+
*/
|
|
15
|
+
/** The signals the wrapper passes on. Each one starts the child's graceful shutdown. */
|
|
16
|
+
export declare const FORWARDED_SIGNALS: readonly ["SIGTERM", "SIGINT"];
|
|
17
|
+
export interface RelaunchChild {
|
|
18
|
+
kill(signal: NodeJS.Signals): boolean;
|
|
19
|
+
readonly exitCode: number | null;
|
|
20
|
+
readonly signalCode: NodeJS.Signals | null;
|
|
21
|
+
}
|
|
22
|
+
export interface SignalSource {
|
|
23
|
+
on(event: NodeJS.Signals, listener: () => void): unknown;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Pass each forwarded signal on to the child while it is still running. Once the
|
|
27
|
+
* wrapper has a handler it no longer exits on the signal itself: it exits when the
|
|
28
|
+
* child does, through the child's exit listener.
|
|
29
|
+
*/
|
|
30
|
+
export declare function forwardSignals(child: RelaunchChild, source?: SignalSource): void;
|
|
31
|
+
/**
|
|
32
|
+
* The wrapper's exit code for how the child ended: its own code, or 128 plus the
|
|
33
|
+
* signal number when a signal killed it, the way a shell reports it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function relaunchExitCode(code: number | null, signal: NodeJS.Signals | null): number;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal hand-off for the Bun relaunch.
|
|
3
|
+
*
|
|
4
|
+
* Under Node, the CLI relaunches itself under Bun as a child and parks (cli.ts). The
|
|
5
|
+
* child owns the proxy and its graceful shutdown (drain, ledger and key flush), but a
|
|
6
|
+
* SIGTERM sent to the wrapper never reached it. In a container the wrapper is PID 1,
|
|
7
|
+
* and the kernel drops a signal PID 1 has no handler for, so `docker stop` waited out
|
|
8
|
+
* its whole grace period and then SIGKILLed both processes: every recreate took the
|
|
9
|
+
* full stop timeout and the shutdown flush never ran.
|
|
10
|
+
*
|
|
11
|
+
* `forwardSignals` passes SIGTERM and SIGINT on to the child, and `relaunchExitCode`
|
|
12
|
+
* turns the child's exit into the wrapper's. Both take their process and child as
|
|
13
|
+
* arguments, so they are tested without spawning anything.
|
|
14
|
+
*/
|
|
15
|
+
import { constants } from 'node:os';
|
|
16
|
+
/** The signals the wrapper passes on. Each one starts the child's graceful shutdown. */
|
|
17
|
+
export const FORWARDED_SIGNALS = ['SIGTERM', 'SIGINT'];
|
|
18
|
+
/**
|
|
19
|
+
* Pass each forwarded signal on to the child while it is still running. Once the
|
|
20
|
+
* wrapper has a handler it no longer exits on the signal itself: it exits when the
|
|
21
|
+
* child does, through the child's exit listener.
|
|
22
|
+
*/
|
|
23
|
+
export function forwardSignals(child, source = process) {
|
|
24
|
+
for (const signal of FORWARDED_SIGNALS) {
|
|
25
|
+
source.on(signal, () => {
|
|
26
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
27
|
+
child.kill(signal);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The wrapper's exit code for how the child ended: its own code, or 128 plus the
|
|
33
|
+
* signal number when a signal killed it, the way a shell reports it.
|
|
34
|
+
*/
|
|
35
|
+
export function relaunchExitCode(code, signal) {
|
|
36
|
+
if (code !== null)
|
|
37
|
+
return code;
|
|
38
|
+
if (signal !== null)
|
|
39
|
+
return 128 + (constants.signals[signal] ?? 0);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
package/dist/stream-drain.d.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* `--drain-on-close` / `DARIO_DRAIN_ON_CLOSE=1` flips the tradeoff: when
|
|
16
16
|
* the downstream client disconnects, dario suppresses the upstream abort
|
|
17
17
|
* and keeps the reader loop spinning until the upstream emits its final
|
|
18
|
-
* event
|
|
19
|
-
*
|
|
18
|
+
* event, or until the upstream timeout ends a stream that has gone quiet
|
|
19
|
+
* (a dead upstream is not waited on). Writes to the closed `res` are gated off;
|
|
20
20
|
* the reads and any accumulator state (analytics, tool-map) continue so
|
|
21
21
|
* the captured usage numbers are complete rather than truncated.
|
|
22
22
|
*
|
|
@@ -48,7 +48,8 @@ export type ClientCloseAction = 'abort' | 'drain' | 'noop';
|
|
|
48
48
|
* `'noop'` — already finished / already aborted; handler should return.
|
|
49
49
|
* `'abort'` — fire `upstreamAbort.abort()` (the v3.24-and-earlier default).
|
|
50
50
|
* `'drain'` — leave upstream alive; gate off client writes; let the
|
|
51
|
-
* read loop consume to EOF (
|
|
51
|
+
* read loop consume to EOF (until the stream goes quiet
|
|
52
|
+
* for UPSTREAM_TIMEOUT_MS).
|
|
52
53
|
*/
|
|
53
54
|
export declare function decideOnClientClose(writableEnded: boolean, upstreamAborted: boolean, drainOnClose: boolean): ClientCloseAction;
|
|
54
55
|
/**
|
package/dist/stream-drain.js
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* `--drain-on-close` / `DARIO_DRAIN_ON_CLOSE=1` flips the tradeoff: when
|
|
16
16
|
* the downstream client disconnects, dario suppresses the upstream abort
|
|
17
17
|
* and keeps the reader loop spinning until the upstream emits its final
|
|
18
|
-
* event
|
|
19
|
-
*
|
|
18
|
+
* event, or until the upstream timeout ends a stream that has gone quiet
|
|
19
|
+
* (a dead upstream is not waited on). Writes to the closed `res` are gated off;
|
|
20
20
|
* the reads and any accumulator state (analytics, tool-map) continue so
|
|
21
21
|
* the captured usage numbers are complete rather than truncated.
|
|
22
22
|
*
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
* `'noop'` — already finished / already aborted; handler should return.
|
|
48
48
|
* `'abort'` — fire `upstreamAbort.abort()` (the v3.24-and-earlier default).
|
|
49
49
|
* `'drain'` — leave upstream alive; gate off client writes; let the
|
|
50
|
-
* read loop consume to EOF (
|
|
50
|
+
* read loop consume to EOF (until the stream goes quiet
|
|
51
|
+
* for UPSTREAM_TIMEOUT_MS).
|
|
51
52
|
*/
|
|
52
53
|
export function decideOnClientClose(writableEnded, upstreamAborted, drainOnClose) {
|
|
53
54
|
if (writableEnded || upstreamAborted)
|
package/dist/tui/tabs/config.js
CHANGED
|
@@ -38,7 +38,7 @@ const FIELDS = [
|
|
|
38
38
|
{ path: 'thinkTime.maxMs', label: 'Think-time cap (ms)', type: 'number', hint: 'upper bound for the whole formula' },
|
|
39
39
|
{ path: 'sessionStart.minMs', label: 'Session-start min', type: 'number', hint: 'first-request delay floor' },
|
|
40
40
|
{ path: 'sessionStart.jitterMs', label: 'Session-start jitter', type: 'number' },
|
|
41
|
-
{ path: 'pool.strategy', label: 'Pool strategy', type: 'string', hint: '"headroom" (default), "expiring-first" or "fill-first"' },
|
|
41
|
+
{ path: 'pool.strategy', label: 'Pool strategy', type: 'string', hint: '"headroom" (default), "headroom-by-plan", "expiring-first" or "fill-first"' },
|
|
42
42
|
// ── Overage-guard (v4.1, dario#288) ─────────────────────────
|
|
43
43
|
{ path: 'overageGuard.enabled', label: 'Overage-guard', type: 'bool', hint: 'halt proxy on any representative-claim=overage' },
|
|
44
44
|
{ path: 'overageGuard.behavior', label: 'Overage behavior', type: 'string', hint: '"halt" (default) or "warn"' },
|
|
@@ -271,7 +271,7 @@ function commitEdit(state) {
|
|
|
271
271
|
*/
|
|
272
272
|
const STRING_ENUMS = {
|
|
273
273
|
'overageGuard.behavior': ['halt', 'warn'],
|
|
274
|
-
'pool.strategy': ['headroom', 'fill-first', 'expiring-first'],
|
|
274
|
+
'pool.strategy': ['headroom', 'headroom-by-plan', 'fill-first', 'expiring-first'],
|
|
275
275
|
};
|
|
276
276
|
function doSave(state) {
|
|
277
277
|
try {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Injectable clock and timers; tests drive them by hand. */
|
|
2
|
+
export interface DeadlineTimers {
|
|
3
|
+
setTimeout: (fn: () => void, ms: number) => unknown;
|
|
4
|
+
clearTimeout: (handle: unknown) => void;
|
|
5
|
+
/** A monotonic ms clock: a wall-clock jump must not end a live stream. */
|
|
6
|
+
now: () => number;
|
|
7
|
+
}
|
|
8
|
+
export interface UpstreamDeadline {
|
|
9
|
+
/**
|
|
10
|
+
* The response is a 2xx and its body is about to be read: from here `ms`
|
|
11
|
+
* is an idle gap, counted from now. Only the first call has an effect.
|
|
12
|
+
*/
|
|
13
|
+
streaming(): void;
|
|
14
|
+
/** A chunk arrived from upstream. Resets the idle gap; ignored before `streaming()`. */
|
|
15
|
+
touch(): void;
|
|
16
|
+
/** Disarm for good, without calling `onTimeout`. Safe to call more than once. */
|
|
17
|
+
clear(): void;
|
|
18
|
+
/** True once `onTimeout` has run. */
|
|
19
|
+
readonly fired: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Arms the headers phase at once. `ms` goes through clampTimerMs, so a value
|
|
23
|
+
* past Node's timer ceiling waits as long as a timer can, not 1 ms.
|
|
24
|
+
*/
|
|
25
|
+
export declare function upstreamDeadline(ms: number, onTimeout: () => void, timers?: DeadlineTimers): UpstreamDeadline;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The upstream timeout, in two phases.
|
|
3
|
+
*
|
|
4
|
+
* Until the response starts streaming, `ms` caps the whole wait: connect,
|
|
5
|
+
* send, and the time to first byte (a retry inside the same request counts
|
|
6
|
+
* against the same budget). Once a 2xx body is being read, `ms` is the
|
|
7
|
+
* longest gap allowed between two chunks instead. A single timer armed for
|
|
8
|
+
* the whole request used to abort agent turns that were still streaming
|
|
9
|
+
* after five minutes (max_tokens is 64000), and the mid-stream guard then
|
|
10
|
+
* re-sent the full prompt to continue, or truncated an open tool_use block.
|
|
11
|
+
* A stream that stops sending still dies `ms` after its last chunk.
|
|
12
|
+
*
|
|
13
|
+
* `touch()` runs once per chunk, so it only records the time: the timer is
|
|
14
|
+
* re-armed for the remainder when it fires early, not on every chunk.
|
|
15
|
+
*/
|
|
16
|
+
import { clampTimerMs } from './timer-ms.js';
|
|
17
|
+
const defaultTimers = {
|
|
18
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
19
|
+
clearTimeout: (h) => clearTimeout(h),
|
|
20
|
+
now: () => performance.now(),
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Arms the headers phase at once. `ms` goes through clampTimerMs, so a value
|
|
24
|
+
* past Node's timer ceiling waits as long as a timer can, not 1 ms.
|
|
25
|
+
*/
|
|
26
|
+
export function upstreamDeadline(ms, onTimeout, timers = defaultTimers) {
|
|
27
|
+
const limit = clampTimerMs(ms);
|
|
28
|
+
let phase = 'headers';
|
|
29
|
+
let lastChunkAt = 0;
|
|
30
|
+
let handle = null;
|
|
31
|
+
let fired = false;
|
|
32
|
+
const disarm = () => {
|
|
33
|
+
if (handle !== null) {
|
|
34
|
+
timers.clearTimeout(handle);
|
|
35
|
+
handle = null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const arm = (delay) => {
|
|
39
|
+
handle = timers.setTimeout(onFire, clampTimerMs(delay));
|
|
40
|
+
};
|
|
41
|
+
function onFire() {
|
|
42
|
+
handle = null;
|
|
43
|
+
if (phase === 'done')
|
|
44
|
+
return;
|
|
45
|
+
if (phase === 'idle') {
|
|
46
|
+
const idle = timers.now() - lastChunkAt;
|
|
47
|
+
if (idle < limit) {
|
|
48
|
+
arm(limit - idle);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
phase = 'done';
|
|
53
|
+
fired = true;
|
|
54
|
+
onTimeout();
|
|
55
|
+
}
|
|
56
|
+
arm(limit);
|
|
57
|
+
return {
|
|
58
|
+
streaming() {
|
|
59
|
+
if (phase !== 'headers')
|
|
60
|
+
return;
|
|
61
|
+
phase = 'idle';
|
|
62
|
+
lastChunkAt = timers.now();
|
|
63
|
+
disarm();
|
|
64
|
+
arm(limit);
|
|
65
|
+
},
|
|
66
|
+
touch() {
|
|
67
|
+
if (phase === 'idle')
|
|
68
|
+
lastChunkAt = timers.now();
|
|
69
|
+
},
|
|
70
|
+
clear() {
|
|
71
|
+
phase = 'done';
|
|
72
|
+
disarm();
|
|
73
|
+
},
|
|
74
|
+
get fired() { return fired; },
|
|
75
|
+
};
|
|
76
|
+
}
|
package/docs/commands.md
CHANGED
|
@@ -53,7 +53,7 @@ This page is the per-flag reference. For environment variables grouped by task
|
|
|
53
53
|
| `--session-rotate-jitter=<ms>` / `DARIO_SESSION_JITTER_MS` | Jitter sampled once per session at creation — hides the exact idle floor. (v3.28) | `0` |
|
|
54
54
|
| `--session-max-age=<ms>` / `DARIO_SESSION_MAX_AGE_MS` | Hard ceiling on a session-id's lifetime regardless of activity. (v3.28) | off |
|
|
55
55
|
| `--session-per-client` / `DARIO_SESSION_PER_CLIENT=1` | Split session-id registry by a per-client header so multi-UI fan-out doesn't collapse onto one id. (v3.28) | off |
|
|
56
|
-
| `--pool-strategy=<headroom\|fill-first\|expiring-first>` / `DARIO_POOL_STRATEGY` | Where new conversations land in a multi-account pool. `headroom` spreads them to the seat with the most slack; `fill-first` concentrates them on the alphabetically-first eligible seat until it drains to the floor, then spills to the next
|
|
56
|
+
| `--pool-strategy=<headroom\|headroom-by-plan\|fill-first\|expiring-first>` / `DARIO_POOL_STRATEGY` | Where new conversations land in a multi-account pool. `headroom` spreads them to the seat with the most slack; `fill-first` concentrates them on the alphabetically-first eligible seat until it drains to the floor, then spills to the next: primary/backup semantics, alias naming (`1-main`, `2-overflow`) picks the fill order; `expiring-first` does the same in order of each seat's 7-day reset, soonest first, so capacity that expires soonest is spent first. `headroom-by-plan` (opt-in) is headroom with each seat's slack counted by plan size (Max Nx counts N, Pro 1). Sticky bindings behave identically under all of them. See [Multi-account pool](./multi-account-pool.md#routing-strategy). | `headroom` |
|
|
57
57
|
| `--pool-fallback=<models>` / `DARIO_POOL_FALLBACK` / config `poolFallback.model` | Strictly opt-in. When every pool seat is drained or in auth cool-down (at selection, or mid-flight on a 429 with no peer left), serve the request as `<model>` from whichever provider can, instead of surfacing the 429/503. Accepts a **chain** — `gpt-5.6-sol,claude-sonnet-5` — read left to right, each provider taking the first entry it can serve. A Codex/ChatGPT subscription that lists the model is preferred (no per-token cost) and works on **both** wire shapes; otherwise a configured openai-compat backend, which is still OpenAI-path only (no Messages translation on that route). A chain also makes failover **symmetric**: a rate-limited or failing subscription hands the request back to the Claude pool. Only a 429/5xx fails over — a 400 surfaces, since a bad request would just reproduce itself elsewhere. Every substituted response carries `x-dario-pool-fallback: <model>` — never silent. Needs a Codex account (`dario add altman`) or a backend (`dario backend add …`); `dario doctor` reports it as INERT with neither. Empty pool still 503s (setup error, not traffic to re-bill). Empty flag value disables, overriding env + config. See [Pool-exhausted fallback](./multi-account-pool.md#pool-exhausted-fallback). | off |
|
|
58
58
|
| `--family-fallback=<family=model,...>` / `DARIO_FAMILY_FALLBACK` / config `familyFallback` | Strictly opt-in. When a rate limit keeps every pool seat off a model family, serve that family's requests as `<model>` on the Claude pool, while some seat can serve the target's family. Written for Fable: its included credit runs out while the seat still has room for Opus, so `fable=claude-opus-5-5` keeps Fable traffic on the subscription instead of a local 429 until the credit resets. The target must be a `claude-*` model of another family; one step, never chained. Runs before `--pool-fallback`, so a pool with room for the target never leaves the Claude pool. Skipped in upstream API-key mode, under `--model`/`--fast-model` and for a seat-pinned request. A `[1m]` tag on the request carries over. Every switched response carries `x-dario-family-fallback: <requested model>`. Empty flag value disables, overriding env + config. See [Family fallback](./multi-account-pool.md#family-fallback). | off |
|
|
59
59
|
| `--system-prompt=<verbatim\|partial\|aggressive\|filepath>` / `DARIO_SYSTEM_PROMPT` | System-prompt mode for outbound CC-shaped requests. `partial` strips behavioral constraints (Tone-and-style, Text-output, scope/verbosity/comment bullets) for ~1.2–2.8× output capability on open-ended work. `aggressive` adds prompt-level RLHF restatement removal (<3% over partial — alignment is RLHF-trained). `<filepath>` fully replaces the slot with file contents. Empirically validated as unfingerprinted by the billing classifier — see [`system-prompt.md`](./system-prompt.md) and [`research/system-prompt-classifier-study.md`](./research/system-prompt-classifier-study.md). (v3.34) | `verbatim` |
|
package/docs/configuration.md
CHANGED
|
@@ -41,6 +41,7 @@ Halts the proxy when an upstream response reports `representative-claim: overage
|
|
|
41
41
|
| `DARIO_MAX_CONCURRENT` | `--max-concurrent=N` | `10` | in-flight ceiling |
|
|
42
42
|
| `DARIO_MAX_QUEUED` | `--max-queued=N` | `128` | buffered waiting for a slot; over this, dario returns 429 `queue-full` |
|
|
43
43
|
| `DARIO_QUEUE_TIMEOUT_MS` | `--queue-timeout=MS` | `60000` | a queued request waiting longer gets 504 `queue-timeout` |
|
|
44
|
+
| `DARIO_UPSTREAM_TIMEOUT_MS` | `--upstream-timeout=MS` | `300000` (5 min) | until the response streams, the whole wait for upstream (504 `upstream-timeout`); while a 2xx streams, the longest gap between two chunks, so a long answer that is still sending is never cut. `0` keeps the default |
|
|
44
45
|
| `DARIO_MAX_CONCURRENT_PER_CONSUMER` | `--max-concurrent-per-consumer=N` | `0` (off) | in-flight ceiling per consumer, keyed by the `x-dario-consumer` request header; a consumer at the cap waits in the queue while everyone else keeps flowing. See [Consumers](./multi-account-pool.md#consumers-who-a-request-is-for) |
|
|
45
46
|
|
|
46
47
|
## Named keys
|
|
@@ -33,6 +33,8 @@ Alias order is the operator's knob: name seats `1-main` / `2-overflow` to pick t
|
|
|
33
33
|
|
|
34
34
|
`--pool-strategy=expiring-first` is fill-first ordered by **when each seat's capacity expires** instead of by alias: new conversations fill the seat whose 7-day window resets soonest (`anthropic-ratelimit-unified-7d-reset`) until it drains to the floor, then spill to the next-soonest; failover follows the same order. Subscription capacity is use-it-or-lose-it, so this is the order that gets the most of it used — the case it was built for is a seat that just got a limit reset, or joined the pool mid-week, sitting beside one whose window has days left. A seat with no 7-day reading yet, or whose window has already rolled, goes last; ties break by alias, so a pool with no readings behaves exactly like fill-first.
|
|
35
35
|
|
|
36
|
+
`--pool-strategy=headroom-by-plan` is headroom with each seat's headroom counted by its plan size: a Max Nx seat counts N times (from the profile's `rate_limit_tier`), Pro and any plan dario cannot read count once. A Max 20x seat at 30% headroom then outranks a Pro at 90%. It is opt-in and never the default, because the ratios dario reads are account-wide (they include the owner's own use of the seat), no absolute capacity reaches the wire, and the window that binds matters more than the plan name: a smaller plan often has more real room. Use it when your seats are otherwise idle and you want the bigger plan to carry proportionally more. Floors, parking and sticky bindings work exactly as under `headroom`.
|
|
37
|
+
|
|
36
38
|
## Session stickiness
|
|
37
39
|
|
|
38
40
|
Multi-turn agent sessions pin to one account for the life of the conversation, so the Anthropic prompt cache isn't destroyed by account rotation between turns.
|
|
@@ -96,7 +98,7 @@ dario proxy --family-fallback=fable=claude-opus-5-5
|
|
|
96
98
|
- **Not for.** Upstream API-key mode (no pool), a server-wide `--model`/`--fast-model` (which replaces the name anyway) and a seat-pinned request (`x-dario-account`), which asked for that seat as it is.
|
|
97
99
|
- **Shape.** The source is a family (`opus`, `sonnet`, `haiku`, `fable`); the target a `claude-*` model of another family. One step, never chained. Bad entries are dropped with a warning at startup and in `dario doctor`. A `[1m]` tag on the request carries over to a target without one.
|
|
98
100
|
|
|
99
|
-
The first Fable request after the credit runs out still goes upstream and comes back `429`: that answer is what parks Fable on the seat. Every Fable request after it is switched, including after the seat has served Opus since.
|
|
101
|
+
The first Fable request after the credit runs out still goes upstream and comes back `429`: that answer is what parks Fable on the seat. dario does not hand that `429` to the client: it sends the same request again, as the client wrote it, through its own front door, where the fallback now applies and the request is built for the target model (its own template, betas and effort). So is the first Fable request after a restart, which is when dario has not seen the credit spent yet. That re-sent request is never re-sent itself, and it does not wait on its own queue slot. Every Fable request after it is switched before it is sent, including after the seat has served Opus since.
|
|
100
102
|
|
|
101
103
|
## In-flight 429 failover
|
|
102
104
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.16.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": {
|