@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.
@@ -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
+ }
@@ -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 !== 'fill-first'
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
- fill-first concentrates them on the
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.on('exit', (code) => process.exit(code ?? 0));
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
  }
@@ -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
- const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
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
- clearTimeout(timeout);
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
- const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
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
- clearTimeout(timeout);
1495
+ timeout.clear();
1488
1496
  res.removeListener('close', onClientClose);
1489
1497
  }
1490
1498
  }
@@ -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
  };
@@ -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 === 'fill-first'
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
- const timer = setTimeout(() => abort.abort(), r.timeoutMs);
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
- clearTimeout(timer);
926
+ timer.clear();
923
927
  }
924
928
  }
925
929
  log(line) {
@@ -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
- const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
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
- clearTimeout(timeout);
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
- clearTimeout(timeout);
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 type PoolStrategy = 'headroom' | 'fill-first' | 'expiring-first';
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
- function pickMaxHeadroom(accounts, family) {
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 bestHeadroom = computeHeadroom(best.rateLimit, family);
892
+ let bestScore = score(best);
872
893
  for (let i = 1; i < accounts.length; i++) {
873
- const h = computeHeadroom(accounts[i].rateLimit, family);
874
- if (h > bestHeadroom) {
894
+ const s = score(accounts[i]);
895
+ if (s > bestScore) {
875
896
  best = accounts[i];
876
- bestHeadroom = h;
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). Injectable so tests can exercise the
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
- const UPSTREAM_TIMEOUT_MS = 300_000; // 5 min — matches Anthropic SDK default
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
- const timer = setTimeout(() => abort.abort(), upstreamTimeoutMs);
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
- clearTimeout(timer);
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 5-minute
5105
- // upstream timeout shares the same controller, so a hung upstream
5106
- // still gets cut off regardless of drain mode.
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
- upstreamTimeout = setTimeout(() => {
5124
+ const deadline = upstreamDeadline(upstreamTimeoutMs, () => {
5110
5125
  if (!upstreamAbort.signal.aborted) {
5111
5126
  upstreamAbortReason = 'timeout';
5112
5127
  upstreamAbort.abort();
5113
5128
  }
5114
- }, upstreamTimeoutMs);
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
- triedAliases.add(nextAccount.alias);
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
- triedAliases.add(nextAccount.alias);
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
- triedAliases.add(nextAccount.alias);
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
- clearTimeout(upstreamTimeout);
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
+ }
@@ -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 (or `UPSTREAM_TIMEOUT_MS` fires as a hard ceiling — we don't
19
- * linger on dead upstreams). Writes to the closed `res` are gated off;
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 (bounded by UPSTREAM_TIMEOUT_MS).
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
  /**
@@ -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 (or `UPSTREAM_TIMEOUT_MS` fires as a hard ceiling — we don't
19
- * linger on dead upstreams). Writes to the closed `res` are gated off;
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 (bounded by UPSTREAM_TIMEOUT_MS).
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)
@@ -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 — 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. Sticky bindings behave identically under all three. See [Multi-account pool](./multi-account-pool.md#routing-strategy). | `headroom` |
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` |
@@ -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.15.0",
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": {