@askalf/dario 3.31.13 → 3.31.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -202,6 +202,8 @@ dario accounts list
202
202
  dario proxy
203
203
  ```
204
204
 
205
+ If you already have a single-account `dario login` set up and run `dario accounts add <alias>` for the first time, dario **back-fills** your existing login credentials into the pool under the reserved alias `login` before running OAuth for the new alias. Net effect: your first `accounts add` gives you two pool accounts (login + new alias), pool mode activates immediately. Back-fill is one-shot, idempotent, and never touches your existing `credentials.json` — if you later `dario accounts remove` below the 2+ threshold, single-account mode reads it unchanged. Skipped if you explicitly pick `login` as the new alias — your intent wins.
206
+
205
207
  Each request picks the account with the highest headroom:
206
208
 
207
209
  ```
@@ -340,7 +342,7 @@ A version marker (`<!-- dario-sub-agent-version: X -->`) embedded in the markdow
340
342
  | `dario status` | Show Claude backend OAuth token health and expiry |
341
343
  | `dario refresh` | Force an immediate Claude token refresh |
342
344
  | `dario logout` | Delete stored Claude credentials |
343
- | `dario accounts list` / `add <alias>` / `remove <alias>` | Multi-account pool management |
345
+ | `dario accounts list` / `add <alias>` / `remove <alias>` | Multi-account pool management. `add <alias>` on a fresh pool auto back-fills your existing `dario login` credentials as `login`, so your first `add` trips the 2+ pool threshold on its own — see [Multi-account pool mode](#multi-account-pool-mode). |
344
346
  | `dario backend list` / `add <name> --key=<key> [--base-url=<url>]` / `remove <name>` | OpenAI-compat backend management |
345
347
  | `dario shim -- <cmd> [args...]` | Run a child process with the in-process fetch patch (see [Shim mode](#shim-mode)) |
346
348
  | `dario subagent install` / `remove` / `status` | CC sub-agent lifecycle (v3.26 — see [sub-agent hook](#claude-code-sub-agent-hook-v326)) |
@@ -762,6 +764,17 @@ Yes — anything that speaks the OpenAI Chat Completions API. Groq, OpenRouter,
762
764
  **Something's wrong. Where do I start?**
763
765
  `dario doctor`. One command, one aggregated report — dario version, Node, platform, runtime/TLS classification, CC binary compat, template source + age + drift, OAuth status, pool state, backends, sub-agent install state, home dir. Exit code 1 if any check fails. Paste the output when you file an issue. (If you're inside Claude Code, `dario subagent install` once and then ask CC to "use the dario sub-agent to run doctor" — same output, no context switch.)
764
766
 
767
+ **OpenClaw returns 401 after I set `DARIO_API_KEY` (or upgrade past v3.30.6).**
768
+ If you run `dario proxy --host=0.0.0.0` (non-loopback), dario requires `DARIO_API_KEY` to be set so it's not an open subscription relay. OpenClaw 2026.2.17+ prefers `~/.openclaw/agents/main/agent/auth-profiles.json` over `openclaw.json`'s `apiKey` field or the `ANTHROPIC_API_KEY` env var — so if you have a stale Anthropic token in `auth-profiles.json` from an earlier setup, OpenClaw sends *that* token instead of `dario`, and dario rejects the request with `Authorization present but value mismatch` (visible under `dario proxy -v`, added in v3.31.2).
769
+
770
+ Three fixes, in order of simplicity:
771
+
772
+ 1. **Use loopback.** `dario proxy --host=127.0.0.1` — auth only enforced on non-loopback binds, no `DARIO_API_KEY` required, no OpenClaw changes. Best if you don't actually need LAN reach to dario.
773
+ 2. **Delete the Anthropic auth profile.** Remove the `"anthropic:default"` entry from `~/.openclaw/agents/main/agent/auth-profiles.json`. OpenClaw then falls back through the config chain and picks up `ANTHROPIC_API_KEY=dario` from the env. Confirmed working by [@tetsuco in #97](https://github.com/askalf/dario/issues/97).
774
+ 3. **Overwrite the auth profile.** `openclaw models auth paste-token --provider anthropic` and paste `dario`. Replaces whatever key was in there — keep a backup if you use it elsewhere.
775
+
776
+ Diagnose with `dario proxy -v` — the reject log (v3.31.2+) reports header-name only (never the value, since it may be a real credential you mistyped) and tells you which of the three configs is actually being hit.
777
+
765
778
  **What happens when Anthropic rotates the OAuth config?**
766
779
  Dario auto-detects OAuth config from the installed Claude Code binary. When CC ships a new version with rotated values, dario picks them up on the next run. Cache at `~/.dario/cc-oauth-cache-v6.json`, keyed by the CC binary fingerprint. The cache path version bumps each time the canonical OAuth config shape changes so stale caches regenerate automatically on upgrade — v3 → v4 in v3.19.4 (scope-list flip CC v2.1.104 → v2.1.107), v4 → v5 in v3.31.3 (authorize URL `claude.com/cai/` → `claude.ai/` host normalization), v5 → v6 in v3.31.4 (6-scope restore after CC v2.1.116).
767
780
 
@@ -781,6 +794,9 @@ Env vars win over the file. Set `DARIO_OAUTH_DISABLE_OVERRIDE=1` to force pure a
781
794
  **What happens when Anthropic changes the CC request template?**
782
795
  Dario extracts the live request template from your installed Claude Code binary on startup — the system prompt, tool schemas, user-agent, beta flags, header insertion order, static header values, and top-level request-body key order — and uses those to replay requests instead of a version pinned into dario itself. When CC ships a new version with a tweaked template, the next `dario proxy` run picks it up automatically. Drift detection forces a refresh when the installed CC version changes under dario, and the nightly `cc-drift-watch` workflow catches upstream rotations (client_id, URLs, tool set, version) the day they ship on npm.
783
796
 
797
+ **Why does `dario accounts list` show an account called `login` I never added?**
798
+ That's your existing `dario login` credentials, back-filled into the pool automatically on your first `dario accounts add <alias>`. Pool mode activates at 2+ accounts in `~/.dario/accounts/`, and the single-account `credentials.json` store lives outside that directory — so without the back-fill, one `accounts add` would leave you at 1 pool entry and your login account orphaned. The `login` alias is reserved for this path. Safe to `dario accounts remove login` if you don't want it pooled; the original `credentials.json` is untouched by the back-fill, so single-account mode resumes reading it after removal drops you below the 2+ threshold. See [Multi-account pool mode](#multi-account-pool-mode) for the full picture.
799
+
784
800
  **First time setup on a fresh Claude account.**
785
801
  If dario is the first thing you run against a brand-new Claude account, prime the account with a few real Claude Code commands first:
786
802
  ```bash
@@ -23,3 +23,37 @@ export declare function _accountRefreshesInFlightSizeForTest(): number;
23
23
  */
24
24
  export declare function addAccountViaOAuth(alias: string): Promise<AccountCredentials>;
25
25
  export declare function getAccountsDir(): string;
26
+ /**
27
+ * Alias reserved for credentials auto-migrated from the single-account
28
+ * `dario login` store. Named `login` so it's semantically obvious where
29
+ * the entry came from and unlikely to collide with user-chosen aliases
30
+ * like `work`, `personal`, etc. If a user specifically requests `login`
31
+ * as the alias for `dario accounts add`, the caller falls back to
32
+ * `default` so the migration doesn't step on the user's intent.
33
+ */
34
+ export declare const MIGRATED_LOGIN_ALIAS = "login";
35
+ /**
36
+ * Promote the user's existing single-account `dario login` credentials
37
+ * (`~/.dario/credentials.json`, `~/.claude/.credentials.json`, or OS
38
+ * keychain — whichever `loadCredentials` finds) into the pool under a
39
+ * reserved alias.
40
+ *
41
+ * Why: the pool activation threshold is 2+ accounts in `~/.dario/accounts/`.
42
+ * A user with one `dario login` account + one `dario accounts add bar`
43
+ * ends up with only one account in `accounts/` (bar), pool mode never
44
+ * trips, and the login account is effectively orphaned while pool is off.
45
+ * Calling this on the first `dario accounts add` back-fills the login
46
+ * account into the pool so the second `add` crosses the threshold.
47
+ *
48
+ * Idempotent: no-op if `accounts/` already has any entry, no-op if no
49
+ * credentials are reachable anywhere. Returns the alias written to, or
50
+ * `null` when nothing happened.
51
+ *
52
+ * The source `credentials.json` (if present) is left untouched — single-
53
+ * account mode still reads it if the user later `accounts remove`s down
54
+ * below the pool threshold. Migration is copy-only, never destructive.
55
+ *
56
+ * @param preferredAlias caller may request a specific alias. If it's
57
+ * already the reserved `login` (or collides), falls back to `default`.
58
+ */
59
+ export declare function ensureLoginCredentialsInPool(alias?: string): Promise<string | null>;
package/dist/accounts.js CHANGED
@@ -2,10 +2,15 @@
2
2
  * Multi-account credential storage.
3
3
  *
4
4
  * Accounts live at `~/.dario/accounts/<alias>.json`. Single-account dario
5
- * still uses `~/.dario/credentials.json` and does not touch this module.
6
- * When `~/.dario/accounts/` contains 2+ files the proxy activates pool mode
7
- * (see pool.ts). Each account has its own independent OAuth lifecycle and
8
- * can refresh without affecting the others.
5
+ * uses `~/.dario/credentials.json` (plus the CC file + OS keychain fallback
6
+ * paths in oauth.ts). When `~/.dario/accounts/` contains 2+ files the proxy
7
+ * activates pool mode (see pool.ts). Each account has its own independent
8
+ * OAuth lifecycle and can refresh without affecting the others.
9
+ *
10
+ * `ensureLoginCredentialsInPool` (below) bridges the two stores on the
11
+ * first `dario accounts add` — it promotes the user's existing login
12
+ * credentials into the pool under a reserved alias so that adding a
13
+ * second account actually trips the 2+ threshold and activates pooling.
9
14
  *
10
15
  * OAuth config (client_id, scopes, authorize URL, token URL) comes from
11
16
  * dario's cc-oauth-detect scanner — the same source the single-account
@@ -17,6 +22,7 @@ import { homedir } from 'node:os';
17
22
  import { randomUUID, randomBytes, createHash } from 'node:crypto';
18
23
  import { createServer } from 'node:http';
19
24
  import { detectCCOAuthConfig } from './cc-oauth-detect.js';
25
+ import { loadCredentials } from './oauth.js';
20
26
  const DARIO_DIR = join(homedir(), '.dario');
21
27
  const ACCOUNTS_DIR = join(DARIO_DIR, 'accounts');
22
28
  /**
@@ -308,3 +314,61 @@ export async function addAccountViaOAuth(alias) {
308
314
  export function getAccountsDir() {
309
315
  return ACCOUNTS_DIR;
310
316
  }
317
+ /**
318
+ * Alias reserved for credentials auto-migrated from the single-account
319
+ * `dario login` store. Named `login` so it's semantically obvious where
320
+ * the entry came from and unlikely to collide with user-chosen aliases
321
+ * like `work`, `personal`, etc. If a user specifically requests `login`
322
+ * as the alias for `dario accounts add`, the caller falls back to
323
+ * `default` so the migration doesn't step on the user's intent.
324
+ */
325
+ export const MIGRATED_LOGIN_ALIAS = 'login';
326
+ /**
327
+ * Promote the user's existing single-account `dario login` credentials
328
+ * (`~/.dario/credentials.json`, `~/.claude/.credentials.json`, or OS
329
+ * keychain — whichever `loadCredentials` finds) into the pool under a
330
+ * reserved alias.
331
+ *
332
+ * Why: the pool activation threshold is 2+ accounts in `~/.dario/accounts/`.
333
+ * A user with one `dario login` account + one `dario accounts add bar`
334
+ * ends up with only one account in `accounts/` (bar), pool mode never
335
+ * trips, and the login account is effectively orphaned while pool is off.
336
+ * Calling this on the first `dario accounts add` back-fills the login
337
+ * account into the pool so the second `add` crosses the threshold.
338
+ *
339
+ * Idempotent: no-op if `accounts/` already has any entry, no-op if no
340
+ * credentials are reachable anywhere. Returns the alias written to, or
341
+ * `null` when nothing happened.
342
+ *
343
+ * The source `credentials.json` (if present) is left untouched — single-
344
+ * account mode still reads it if the user later `accounts remove`s down
345
+ * below the pool threshold. Migration is copy-only, never destructive.
346
+ *
347
+ * @param preferredAlias caller may request a specific alias. If it's
348
+ * already the reserved `login` (or collides), falls back to `default`.
349
+ */
350
+ export async function ensureLoginCredentialsInPool(alias = MIGRATED_LOGIN_ALIAS) {
351
+ if (!safeAliasPath(alias))
352
+ return null;
353
+ const existing = await listAccountAliases();
354
+ if (existing.length > 0)
355
+ return null;
356
+ const creds = await loadCredentials();
357
+ const tok = creds?.claudeAiOauth;
358
+ if (!tok?.accessToken || !tok?.refreshToken)
359
+ return null;
360
+ const identity = (await detectClaudeIdentity()) ?? {
361
+ deviceId: randomUUID(),
362
+ accountUuid: randomUUID(),
363
+ };
364
+ await saveAccount({
365
+ alias,
366
+ accessToken: tok.accessToken,
367
+ refreshToken: tok.refreshToken,
368
+ expiresAt: tok.expiresAt,
369
+ scopes: tok.scopes ?? [],
370
+ deviceId: identity.deviceId,
371
+ accountUuid: identity.accountUuid,
372
+ });
373
+ return alias;
374
+ }
@@ -131,7 +131,11 @@ export function buildProbeAuthorizeUrl(cfg) {
131
131
  scope: cfg.scopes,
132
132
  code_challenge: pkceChallenge(),
133
133
  code_challenge_method: 'S256',
134
- state: base64url(randomBytes(16)),
134
+ // 32 bytes — match what CC v2.1.116+ actually sends. See dario#71.
135
+ // Shorter states produce "Invalid request format" from Anthropic's
136
+ // authorize endpoint, which the probe classifier would otherwise mis-
137
+ // attribute to drift when it's actually our own request shape.
138
+ state: base64url(randomBytes(32)),
135
139
  });
136
140
  return `${cfg.authorizeUrl}?${params.toString()}`;
137
141
  }
package/dist/cli.js CHANGED
@@ -12,34 +12,22 @@
12
12
  // ── Bun auto-relaunch ──
13
13
  // Bun's TLS fingerprint matches Claude Code's runtime (both use Bun/BoringSSL).
14
14
  // If Bun is installed and we're running on Node, relaunch under Bun for
15
- // network-level fingerprint fidelity.
16
- if (!('Bun' in globalThis) && !process.env.DARIO_NO_BUN) {
17
- try {
18
- const { execFileSync } = await import('node:child_process');
19
- // Check if bun exists
20
- execFileSync('bun', ['--version'], { stdio: 'ignore', timeout: 3000 });
21
- // Relaunch under bun
22
- const { spawn } = await import('node:child_process');
23
- const child = spawn('bun', ['run', ...process.argv.slice(1)], {
24
- stdio: 'inherit',
25
- env: { ...process.env, DARIO_NO_BUN: '1' },
26
- });
27
- child.on('exit', (code) => process.exit(code ?? 0));
28
- // Prevent this process from continuing
29
- await new Promise(() => { });
30
- }
31
- catch {
32
- // Bun not available, continue with Node
33
- }
34
- }
15
+ // network-level fingerprint fidelity. Moved below into the main-entry guard
16
+ // at the bottom of the file so importing this module (e.g. from tests that
17
+ // just want `parsePositiveIntEnv`) doesn't trigger a Bun relaunch or any
18
+ // other startup side effect.
35
19
  import { unlink } from 'node:fs/promises';
36
20
  import { join } from 'node:path';
37
21
  import { homedir } from 'node:os';
22
+ import { pathToFileURL } from 'node:url';
38
23
  import { startAutoOAuthFlow, startManualOAuthFlow, detectHeadlessEnvironment, getStatus, refreshTokens, loadCredentials } from './oauth.js';
39
24
  import { startProxy, sanitizeError } from './proxy.js';
40
25
  import { VALID_EFFORT_VALUES } from './cc-template.js';
41
- import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, removeAccount } from './accounts.js';
26
+ import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, removeAccount, ensureLoginCredentialsInPool, MIGRATED_LOGIN_ALIAS } from './accounts.js';
42
27
  import { listBackends, saveBackend, removeBackend } from './openai-backend.js';
28
+ // `args` / `command` at module scope — command handlers below close over
29
+ // `args` to read their own flags. Reading argv is harmless on import; only
30
+ // the handler dispatch at the bottom is gated behind the main-entry check.
43
31
  const args = process.argv.slice(2);
44
32
  const command = args[0] ?? 'proxy';
45
33
  async function login() {
@@ -463,6 +451,22 @@ async function accounts() {
463
451
  console.error(`[dario] Account "${alias}" already exists. Remove it first with \`dario accounts remove ${alias}\`.`);
464
452
  process.exit(1);
465
453
  }
454
+ // If the user has `dario login` credentials on disk or in the keychain
455
+ // and the pool is empty, migrate those credentials into the pool first.
456
+ // Otherwise the new account lives alone in accounts/, pool mode never
457
+ // trips the 2+ threshold, and the login account is orphaned from the
458
+ // pool until the user figures out they have to re-`accounts add` it.
459
+ // Skip silently when the user explicitly picks the reserved alias —
460
+ // their intent wins, they can run `accounts add` again for the login
461
+ // migration under a different alias.
462
+ if (existing.length === 0 && alias !== MIGRATED_LOGIN_ALIAS) {
463
+ const migrated = await ensureLoginCredentialsInPool();
464
+ if (migrated) {
465
+ console.log('');
466
+ console.log(` Migrated your existing \`dario login\` account into the pool as "${migrated}".`);
467
+ console.log(` (Pool mode activates on 2+ accounts — this back-fill plus "${alias}" crosses that.)`);
468
+ }
469
+ }
466
470
  console.log('');
467
471
  console.log(` Adding account "${alias}" to the pool...`);
468
472
  console.log('');
@@ -1143,13 +1147,42 @@ const commands = {
1143
1147
  '--version': version,
1144
1148
  '-V': version,
1145
1149
  };
1146
- const handler = commands[command];
1147
- if (!handler) {
1148
- console.error(`Unknown command: ${command}`);
1149
- console.error('Run `dario help` for usage.');
1150
- process.exit(1);
1150
+ // Main-entry guard. Only run the Bun auto-relaunch and handler dispatch when
1151
+ // this module is the direct entry point — importing it (from tests or for
1152
+ // a library helper like `parsePositiveIntEnv`) must NOT start the proxy.
1153
+ // Before this guard, `import { parsePositiveIntEnv } from './cli.js'` would
1154
+ // fall through to `command = args[0] ?? 'proxy'` and fire `handler()`, which
1155
+ // tried to run `startProxy()` and failed the test with "Not authenticated".
1156
+ const isDirectEntry = typeof process.argv[1] === 'string' &&
1157
+ import.meta.url === pathToFileURL(process.argv[1]).href;
1158
+ if (isDirectEntry) {
1159
+ // Bun auto-relaunch for TLS fingerprint fidelity. Only meaningful when
1160
+ // dario is the direct entry — if we're imported, whoever imported us
1161
+ // already chose their runtime.
1162
+ if (!('Bun' in globalThis) && !process.env.DARIO_NO_BUN) {
1163
+ try {
1164
+ const { execFileSync, spawn } = await import('node:child_process');
1165
+ execFileSync('bun', ['--version'], { stdio: 'ignore', timeout: 3000 });
1166
+ const child = spawn('bun', ['run', ...process.argv.slice(1)], {
1167
+ stdio: 'inherit',
1168
+ env: { ...process.env, DARIO_NO_BUN: '1' },
1169
+ });
1170
+ child.on('exit', (code) => process.exit(code ?? 0));
1171
+ // Prevent this process from continuing
1172
+ await new Promise(() => { });
1173
+ }
1174
+ catch {
1175
+ // Bun not available, continue with Node
1176
+ }
1177
+ }
1178
+ const handler = commands[command];
1179
+ if (!handler) {
1180
+ console.error(`Unknown command: ${command}`);
1181
+ console.error('Run `dario help` for usage.');
1182
+ process.exit(1);
1183
+ }
1184
+ handler().catch(err => {
1185
+ console.error('Fatal error:', sanitizeError(err));
1186
+ process.exit(1);
1187
+ });
1151
1188
  }
1152
- handler().catch(err => {
1153
- console.error('Fatal error:', sanitizeError(err));
1154
- process.exit(1);
1155
- });
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export type { OAuthTokens, CredentialsFile } from './oauth.js';
9
9
  export { startProxy, sanitizeError } from './proxy.js';
10
10
  export { AccountPool, parseRateLimits } from './pool.js';
11
11
  export type { PoolAccount, PoolStatus, RateLimitSnapshot, AccountIdentity } from './pool.js';
12
- export { listAccountAliases, loadAccount, loadAllAccounts, saveAccount, removeAccount, refreshAccountToken, addAccountViaOAuth, getAccountsDir, } from './accounts.js';
12
+ export { listAccountAliases, loadAccount, loadAllAccounts, saveAccount, removeAccount, refreshAccountToken, addAccountViaOAuth, ensureLoginCredentialsInPool, MIGRATED_LOGIN_ALIAS, getAccountsDir, } from './accounts.js';
13
13
  export type { AccountCredentials } from './accounts.js';
14
14
  export { Analytics } from './analytics.js';
15
15
  export type { RequestRecord, AnalyticsSummary } from './analytics.js';
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ export { startProxy, sanitizeError } from './proxy.js';
10
10
  // contains 2+ accounts; see README for the progression from single-account
11
11
  // mode to pool mode).
12
12
  export { AccountPool, parseRateLimits } from './pool.js';
13
- export { listAccountAliases, loadAccount, loadAllAccounts, saveAccount, removeAccount, refreshAccountToken, addAccountViaOAuth, getAccountsDir, } from './accounts.js';
13
+ export { listAccountAliases, loadAccount, loadAllAccounts, saveAccount, removeAccount, refreshAccountToken, addAccountViaOAuth, ensureLoginCredentialsInPool, MIGRATED_LOGIN_ALIAS, getAccountsDir, } from './accounts.js';
14
14
  export { Analytics } from './analytics.js';
15
15
  // Multi-provider backends (v3.6.0+). Secondary OpenAI-compat providers
16
16
  // (OpenAI, OpenRouter, Groq, local LiteLLM, etc.) configured via
package/dist/oauth.js CHANGED
@@ -198,7 +198,10 @@ async function saveCredentials(creds) {
198
198
  export async function startAutoOAuthFlow() {
199
199
  const { createServer } = await import('node:http');
200
200
  const { codeVerifier, codeChallenge } = generatePKCE();
201
- const state = base64url(randomBytes(16));
201
+ // 32 random bytes → 43-char base64url state. See dario#71 — Anthropic's
202
+ // authorize endpoint rejects shorter states with "Invalid request format";
203
+ // CC v2.1.116+ ships 32. Keep in lockstep with CC's entropy-per-state.
204
+ const state = base64url(randomBytes(32));
202
205
  return new Promise((resolve, reject) => {
203
206
  const server = createServer((req, res) => {
204
207
  const url = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
@@ -387,7 +390,8 @@ export function detectHeadlessEnvironment() {
387
390
  */
388
391
  export async function startManualOAuthFlow() {
389
392
  const { codeVerifier, codeChallenge } = generatePKCE();
390
- const state = base64url(randomBytes(16));
393
+ // 32 bytes — same reason as startAutoOAuthFlow. See dario#71.
394
+ const state = base64url(randomBytes(32));
391
395
  const cfg = await getOAuthConfig();
392
396
  const authUrl = buildManualAuthorizeUrl(cfg, codeChallenge, state);
393
397
  console.log('');
@@ -50,6 +50,16 @@ export interface RequestQueueOptions {
50
50
  maxConcurrent?: number;
51
51
  maxQueued?: number;
52
52
  queueTimeoutMs?: number;
53
+ /**
54
+ * Whether timeout timers are `unref`'d so they don't by themselves keep
55
+ * the Node event loop alive. Default `true` — appropriate for the proxy,
56
+ * where a leaked queue entry should never hang shutdown. Pass `false` in
57
+ * tests where the queue is the only pending work on the loop: an
58
+ * `unref`'d timer won't fire in that case (Node exits with "unsettled
59
+ * top-level await" before the 50ms timeout elapses), so the reject the
60
+ * test is waiting for never arrives.
61
+ */
62
+ unrefTimers?: boolean;
53
63
  }
54
64
  export declare const DEFAULT_MAX_CONCURRENT = 10;
55
65
  export declare const DEFAULT_MAX_QUEUED = 128;
@@ -58,6 +68,7 @@ export declare class RequestQueue {
58
68
  readonly maxConcurrent: number;
59
69
  readonly maxQueued: number;
60
70
  readonly queueTimeoutMs: number;
71
+ readonly unrefTimers: boolean;
61
72
  private active;
62
73
  private queue;
63
74
  constructor(opts?: RequestQueueOptions);
@@ -47,12 +47,14 @@ export class RequestQueue {
47
47
  maxConcurrent;
48
48
  maxQueued;
49
49
  queueTimeoutMs;
50
+ unrefTimers;
50
51
  active = 0;
51
52
  queue = [];
52
53
  constructor(opts = {}) {
53
54
  this.maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
54
55
  this.maxQueued = opts.maxQueued ?? DEFAULT_MAX_QUEUED;
55
56
  this.queueTimeoutMs = opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
57
+ this.unrefTimers = opts.unrefTimers ?? true;
56
58
  }
57
59
  /**
58
60
  * Acquire a concurrency slot. Resolves when admitted; throws
@@ -80,7 +82,9 @@ export class RequestQueue {
80
82
  }, this.queueTimeoutMs);
81
83
  // Keep the timer from pinning the event loop open on shutdown. A queued
82
84
  // request waiting for a slot shouldn't by itself keep the process alive.
83
- timeoutHandle.unref?.();
85
+ // Opt-out for tests — see `unrefTimers` comment in RequestQueueOptions.
86
+ if (this.unrefTimers)
87
+ timeoutHandle.unref?.();
84
88
  const entry = { resolve, reject, enqueuedAt, timeoutHandle };
85
89
  this.queue.push(entry);
86
90
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.31.13",
3
+ "version": "3.31.15",
4
4
  "description": "A local LLM router. One endpoint, every provider — Claude subscriptions, OpenAI, OpenRouter, Groq, local LiteLLM, any OpenAI-compat endpoint — your tools don't need to change.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -69,7 +69,7 @@
69
69
  "node": ">=18.0.0"
70
70
  },
71
71
  "devDependencies": {
72
- "@types/node": "^22.0.0",
72
+ "@types/node": "^25.6.0",
73
73
  "tsx": "^4.19.0",
74
74
  "typescript": "^5.7.0"
75
75
  }