@askalf/dario 6.15.0 → 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 +4 -1
- package/dist/proxy.js +59 -31
- 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 +2 -0
- 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
|
@@ -503,7 +503,10 @@ interface ProxyOptions {
|
|
|
503
503
|
maxConcurrentPerConsumer?: number;
|
|
504
504
|
/**
|
|
505
505
|
* Max ms before the upstream fetch is aborted. Default 300000 (5 min,
|
|
506
|
-
* 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
|
|
507
510
|
* timeout → slot-release path without waiting 5 minutes (dario#905).
|
|
508
511
|
*/
|
|
509
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.
|
|
@@ -3697,7 +3701,9 @@ export async function startProxy(opts = {}) {
|
|
|
3697
3701
|
const onClose = () => { if (!res.writableEnded)
|
|
3698
3702
|
abort.abort(); };
|
|
3699
3703
|
res.on('close', onClose);
|
|
3700
|
-
|
|
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());
|
|
3701
3707
|
try {
|
|
3702
3708
|
const leg = await fetch(`${loopbackBase}${isOpenAI ? '/v1/chat/completions' : '/v1/messages'}`, {
|
|
3703
3709
|
method: 'POST',
|
|
@@ -3714,9 +3720,12 @@ export async function startProxy(opts = {}) {
|
|
|
3714
3720
|
responseHeaders['x-dario-family-fallback'] = fromModel;
|
|
3715
3721
|
res.writeHead(leg.status, responseHeaders);
|
|
3716
3722
|
if (leg.body) {
|
|
3723
|
+
if (leg.ok)
|
|
3724
|
+
timer.streaming();
|
|
3717
3725
|
const reader = leg.body.getReader();
|
|
3718
3726
|
while (true) {
|
|
3719
3727
|
const { done, value } = await reader.read();
|
|
3728
|
+
timer.touch();
|
|
3720
3729
|
if (done)
|
|
3721
3730
|
break;
|
|
3722
3731
|
if (!res.write(value))
|
|
@@ -3737,7 +3746,7 @@ export async function startProxy(opts = {}) {
|
|
|
3737
3746
|
}
|
|
3738
3747
|
}
|
|
3739
3748
|
finally {
|
|
3740
|
-
|
|
3749
|
+
timer.clear();
|
|
3741
3750
|
res.off('close', onClose);
|
|
3742
3751
|
}
|
|
3743
3752
|
return true;
|
|
@@ -4631,6 +4640,10 @@ export async function startProxy(opts = {}) {
|
|
|
4631
4640
|
// Outbound session id resolved once — either inside the template build
|
|
4632
4641
|
// (so body metadata matches) or below for passthrough (no body build).
|
|
4633
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;
|
|
4634
4647
|
// Request context for hybrid-mode field injection (#33). Built once
|
|
4635
4648
|
// per request from incoming headers so the reverse mapper can fill
|
|
4636
4649
|
// client-declared fields like `sessionId` that CC's schema doesn't
|
|
@@ -4876,6 +4889,7 @@ export async function startProxy(opts = {}) {
|
|
|
4876
4889
|
for (const key of Object.keys(r))
|
|
4877
4890
|
delete r[key];
|
|
4878
4891
|
Object.assign(r, ccBody);
|
|
4892
|
+
bodyIdentityOwned = true;
|
|
4879
4893
|
// `X[1m]` is a client-side LABEL, never a valid wire id — real CC
|
|
4880
4894
|
// sends the BASE model id plus the `context-1m-2025-08-07` beta
|
|
4881
4895
|
// (live capture 2026-06-09, CC v2.1.170 with --model
|
|
@@ -5101,17 +5115,19 @@ export async function startProxy(opts = {}) {
|
|
|
5101
5115
|
// upstream fetch so Anthropic stops generating (and billing) a
|
|
5102
5116
|
// response nobody will read. With `--drain-on-close` set, we
|
|
5103
5117
|
// instead keep the reader spinning to consume the full SSE — see
|
|
5104
|
-
// src/stream-drain.ts for the fingerprint rationale. The
|
|
5105
|
-
//
|
|
5106
|
-
//
|
|
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.
|
|
5107
5122
|
const upstreamAbort = new AbortController();
|
|
5108
5123
|
let clientDisconnected = false;
|
|
5109
|
-
|
|
5124
|
+
const deadline = upstreamDeadline(upstreamTimeoutMs, () => {
|
|
5110
5125
|
if (!upstreamAbort.signal.aborted) {
|
|
5111
5126
|
upstreamAbortReason = 'timeout';
|
|
5112
5127
|
upstreamAbort.abort();
|
|
5113
5128
|
}
|
|
5114
|
-
}
|
|
5129
|
+
});
|
|
5130
|
+
upstreamTimeout = deadline;
|
|
5115
5131
|
onClientClose = () => {
|
|
5116
5132
|
const action = decideOnClientClose(res.writableEnded, upstreamAbort.signal.aborted, drainOnClose);
|
|
5117
5133
|
if (action === 'abort') {
|
|
@@ -5157,6 +5173,25 @@ export async function startProxy(opts = {}) {
|
|
|
5157
5173
|
// combination with headroom -- opus-4-1 needs 2.
|
|
5158
5174
|
const MAX_RECOVERY_PASSES = 4;
|
|
5159
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
|
+
};
|
|
5160
5195
|
dispatchLoop: while (true) {
|
|
5161
5196
|
// Reorder outbound headers to match CC's captured header sequence
|
|
5162
5197
|
// when the live template recorded one. No-op on bundled-only installs.
|
|
@@ -5496,13 +5531,7 @@ export async function startProxy(opts = {}) {
|
|
|
5496
5531
|
if (poolAccount) {
|
|
5497
5532
|
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
5498
5533
|
if (nextAccount) {
|
|
5499
|
-
|
|
5500
|
-
poolAccount = nextAccount;
|
|
5501
|
-
accessToken = nextAccount.accessToken;
|
|
5502
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
5503
|
-
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
5504
|
-
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
5505
|
-
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
5534
|
+
switchAccount(nextAccount);
|
|
5506
5535
|
peekedBody = null;
|
|
5507
5536
|
continue dispatchLoop;
|
|
5508
5537
|
}
|
|
@@ -5600,13 +5629,7 @@ export async function startProxy(opts = {}) {
|
|
|
5600
5629
|
}
|
|
5601
5630
|
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
5602
5631
|
if (nextAccount) {
|
|
5603
|
-
|
|
5604
|
-
poolAccount = nextAccount;
|
|
5605
|
-
accessToken = nextAccount.accessToken;
|
|
5606
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
5607
|
-
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
5608
|
-
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
5609
|
-
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
5632
|
+
switchAccount(nextAccount);
|
|
5610
5633
|
continue dispatchLoop;
|
|
5611
5634
|
}
|
|
5612
5635
|
// No peer available — forward the saved generic-403 bytes when the
|
|
@@ -5634,13 +5657,7 @@ export async function startProxy(opts = {}) {
|
|
|
5634
5657
|
if (poolAccount) {
|
|
5635
5658
|
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
5636
5659
|
if (nextAccount) {
|
|
5637
|
-
|
|
5638
|
-
poolAccount = nextAccount;
|
|
5639
|
-
accessToken = nextAccount.accessToken;
|
|
5640
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
5641
|
-
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
5642
|
-
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
5643
|
-
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
5660
|
+
switchAccount(nextAccount);
|
|
5644
5661
|
continue dispatchLoop;
|
|
5645
5662
|
}
|
|
5646
5663
|
}
|
|
@@ -5839,11 +5856,17 @@ export async function startProxy(opts = {}) {
|
|
|
5839
5856
|
// parking this handler forever and leaking its queue slot — the
|
|
5840
5857
|
// dario#905 wedge. See waitForClientDrain in src/stream-drain.ts.
|
|
5841
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();
|
|
5842
5864
|
try {
|
|
5843
5865
|
let buffer = '';
|
|
5844
5866
|
const MAX_LINE_LENGTH = 1_000_000; // 1MB max per SSE line
|
|
5845
5867
|
while (true) {
|
|
5846
5868
|
const { done, value } = await reader.read();
|
|
5869
|
+
deadline.touch();
|
|
5847
5870
|
if (done) {
|
|
5848
5871
|
upstreamDoneAt = Date.now();
|
|
5849
5872
|
break;
|
|
@@ -6128,7 +6151,7 @@ export async function startProxy(opts = {}) {
|
|
|
6128
6151
|
// setup happens after the body-read phase, so on fast-path errors
|
|
6129
6152
|
// (413, body read timeout) these may still be null — guard accordingly.
|
|
6130
6153
|
if (upstreamTimeout !== null)
|
|
6131
|
-
|
|
6154
|
+
upstreamTimeout.clear();
|
|
6132
6155
|
if (onClientClose !== null)
|
|
6133
6156
|
req.off('close', onClientClose);
|
|
6134
6157
|
releaseQueueSlot();
|
|
@@ -6421,8 +6444,13 @@ export async function startProxy(opts = {}) {
|
|
|
6421
6444
|
catch { /* best-effort flush — never block shutdown on it */ }
|
|
6422
6445
|
}));
|
|
6423
6446
|
};
|
|
6424
|
-
// 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;
|
|
6425
6450
|
const shutdown = () => {
|
|
6451
|
+
if (shuttingDown)
|
|
6452
|
+
return;
|
|
6453
|
+
shuttingDown = true;
|
|
6426
6454
|
console.log('\n[dario] Shutting down...');
|
|
6427
6455
|
clearInterval(presenceInterval);
|
|
6428
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.
|
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": {
|