@askalf/dario 5.4.27 → 5.4.28

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.
@@ -34,6 +34,18 @@ export interface HealthStatusLike {
34
34
  mode: 'single';
35
35
  active: number;
36
36
  };
37
+ /**
38
+ * Request-queue snapshot (dario#905), surfaced to internal callers only.
39
+ * `active === maxConcurrent` with `queued > 0` for a sustained period is
40
+ * the slot-exhaustion signature — before this field existed, that state
41
+ * was invisible from outside the process while every request 504'd.
42
+ */
43
+ queue?: {
44
+ active: number;
45
+ queued: number;
46
+ maxConcurrent: number;
47
+ maxQueued: number;
48
+ };
37
49
  }
38
50
  export interface HealthResponse {
39
51
  httpStatus: number;
@@ -82,6 +82,7 @@ export function buildHealthResponse(s, requestCount, includeInternal) {
82
82
  expiresIn: s.expiresIn,
83
83
  requests: requestCount,
84
84
  ...(s.sessions ? { sessions: s.sessions } : {}),
85
+ ...(s.queue ? { queue: s.queue } : {}),
85
86
  ...(s.refreshFailures ? { refreshFailures: s.refreshFailures } : {}),
86
87
  }
87
88
  : liveness;
package/dist/proxy.d.ts CHANGED
@@ -345,6 +345,12 @@ interface ProxyOptions {
345
345
  maxQueued?: number;
346
346
  /** Max ms a queued request waits before it times out with 504. Default 60000. dario#80. */
347
347
  queueTimeoutMs?: number;
348
+ /**
349
+ * Max ms before the upstream fetch is aborted. Default 300000 (5 min,
350
+ * matching the Anthropic SDK). Injectable so tests can exercise the
351
+ * timeout → slot-release path without waiting 5 minutes (dario#905).
352
+ */
353
+ upstreamTimeoutMs?: number;
348
354
  /**
349
355
  * Override the outbound `output_config.effort` value on non-haiku
350
356
  * requests. Default (undefined) pins `'high'`, matching CC 2.1.116's
package/dist/proxy.js CHANGED
@@ -1426,6 +1426,7 @@ export async function startProxy(opts = {}) {
1426
1426
  maxQueued: opts.maxQueued ?? DEFAULT_MAX_QUEUED,
1427
1427
  queueTimeoutMs: opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS,
1428
1428
  });
1429
+ const upstreamTimeoutMs = opts.upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_MS;
1429
1430
  // Cache context-1m beta availability. Set false once per account after the
1430
1431
  // first "long context" rejection, so we skip sending context-1m on every
1431
1432
  // subsequent request instead of paying the round-trip + retry cost each time.
@@ -1528,7 +1529,7 @@ export async function startProxy(opts = {}) {
1528
1529
  // disconnect no longer aborts the upstream fetch — we keep consuming
1529
1530
  // the SSE so Anthropic sees a CC-shaped read-to-EOF pattern. See
1530
1531
  // src/stream-drain.ts for the rationale + tradeoff.
1531
- const { decideOnClientClose, resolveDrainOnClose } = await import('./stream-drain.js');
1532
+ const { decideOnClientClose, resolveDrainOnClose, waitForClientDrain } = await import('./stream-drain.js');
1532
1533
  const drainOnClose = resolveDrainOnClose(opts.drainOnClose);
1533
1534
  if (verbose) {
1534
1535
  console.log(`[dario] drain-on-close: ${drainOnClose ? 'enabled' : 'disabled'}`);
@@ -1709,6 +1710,9 @@ export async function startProxy(opts = {}) {
1709
1710
  sessions: pool.size === 0
1710
1711
  ? { mode: 'single', active: sessionRegistry.size() }
1711
1712
  : { mode: 'pool', stickyBindings: pool.stickyCount() },
1713
+ // Concurrency-slot visibility (dario#905): active pinned at
1714
+ // maxConcurrent with queued > 0 is the slot-exhaustion signature.
1715
+ queue: queue.snapshot(),
1712
1716
  }, requestCount, includeInternal);
1713
1717
  res.writeHead(httpStatus, JSON_HEADERS);
1714
1718
  res.end(JSON.stringify(body));
@@ -1856,7 +1860,10 @@ export async function startProxy(opts = {}) {
1856
1860
  // Always-on as of v4 (pre-v4 this was gated to pool mode).
1857
1861
  if (urlPath === '/analytics' && req.method === 'GET') {
1858
1862
  res.writeHead(200, JSON_HEADERS);
1859
- res.end(JSON.stringify(analytics.summary()));
1863
+ // `queue` rides along the summary (dario#905): request-queue.ts always
1864
+ // documented snapshot() as "exposed for /analytics", but it was never
1865
+ // actually wired in, so slot exhaustion was invisible from outside.
1866
+ res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot() }));
1860
1867
  return;
1861
1868
  }
1862
1869
  // Analytics live stream — SSE of new RequestRecord JSON, one event
@@ -2278,7 +2285,7 @@ export async function startProxy(opts = {}) {
2278
2285
  console.log(`[dario] #${requestCount} ${req.method} ${urlPath} (model: ${rawModel}) → openai backend`);
2279
2286
  }
2280
2287
  requestCount++;
2281
- await forwardToOpenAI(req, res, body, openaiBackend, corsOrigin, SECURITY_HEADERS, UPSTREAM_TIMEOUT_MS, verbose);
2288
+ await forwardToOpenAI(req, res, body, openaiBackend, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose);
2282
2289
  return;
2283
2290
  }
2284
2291
  }
@@ -2303,7 +2310,7 @@ export async function startProxy(opts = {}) {
2303
2310
  }
2304
2311
  console.log(`[dario] #${requestCount} pool exhausted — /v1/chat/completions → ${openaiBackend.name} as ${poolFallbackModel}`);
2305
2312
  requestCount++;
2306
- await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, UPSTREAM_TIMEOUT_MS, verbose);
2313
+ await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, upstreamTimeoutMs, verbose);
2307
2314
  return;
2308
2315
  }
2309
2316
  // Parse body once, apply OpenAI translation, model override, and sanitization
@@ -2750,7 +2757,7 @@ export async function startProxy(opts = {}) {
2750
2757
  upstreamAbortReason = 'timeout';
2751
2758
  upstreamAbort.abort();
2752
2759
  }
2753
- }, UPSTREAM_TIMEOUT_MS);
2760
+ }, upstreamTimeoutMs);
2754
2761
  onClientClose = () => {
2755
2762
  const action = decideOnClientClose(res.writableEnded, upstreamAbort.signal.aborted, drainOnClose);
2756
2763
  if (action === 'abort') {
@@ -3177,7 +3184,7 @@ export async function startProxy(opts = {}) {
3177
3184
  if (fallbackBody) {
3178
3185
  console.log(`[dario] #${requestCount} pool exhausted mid-flight (429, no peer) — /v1/chat/completions → ${openaiBackend.name} as ${poolFallbackModel}`);
3179
3186
  requestCount++;
3180
- await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, UPSTREAM_TIMEOUT_MS, verbose);
3187
+ await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, upstreamTimeoutMs, verbose);
3181
3188
  return;
3182
3189
  }
3183
3190
  }
@@ -3320,12 +3327,11 @@ export async function startProxy(opts = {}) {
3320
3327
  if (res.write(chunk) === false)
3321
3328
  needsDrain = true;
3322
3329
  };
3323
- // Resolves on 'close' too, so a vanished client can't wedge the loop.
3324
- const waitForDrain = () => new Promise((resolve) => {
3325
- const done = () => { res.off('drain', done); res.off('close', done); resolve(); };
3326
- res.once('drain', done);
3327
- res.once('close', done);
3328
- });
3330
+ // Resolves on 'close' (vanished client) AND on upstream abort. The
3331
+ // abort arm is what keeps a connected-but-not-reading client from
3332
+ // parking this handler forever and leaking its queue slot the
3333
+ // dario#905 wedge. See waitForClientDrain in src/stream-drain.ts.
3334
+ const waitForDrain = () => waitForClientDrain(res, upstreamAbort.signal);
3329
3335
  try {
3330
3336
  let buffer = '';
3331
3337
  const MAX_LINE_LENGTH = 1_000_000; // 1MB max per SSE line
@@ -3567,10 +3573,10 @@ export async function startProxy(opts = {}) {
3567
3573
  writeLogLine(logFileStream, { ...errLogBase, reject: 'client-closed' });
3568
3574
  }
3569
3575
  else if (upstreamAbortReason === 'timeout') {
3570
- console.error(`[dario] #${requestCount} upstream timeout after ${UPSTREAM_TIMEOUT_MS / 1000}s`);
3576
+ console.error(`[dario] #${requestCount} upstream timeout after ${upstreamTimeoutMs / 1000}s`);
3571
3577
  if (!res.headersSent) {
3572
3578
  res.writeHead(504, JSON_HEADERS);
3573
- res.end(JSON.stringify({ error: 'Upstream timeout', message: `Anthropic did not respond within ${UPSTREAM_TIMEOUT_MS / 1000}s` }));
3579
+ res.end(JSON.stringify({ error: 'Upstream timeout', message: `Anthropic did not respond within ${upstreamTimeoutMs / 1000}s` }));
3574
3580
  }
3575
3581
  else if (!res.writableEnded) {
3576
3582
  res.end();
@@ -58,3 +58,37 @@ export declare function decideOnClientClose(writableEnded: boolean, upstreamAbor
58
58
  * Explicit `true`/`false` on the options object always wins.
59
59
  */
60
60
  export declare function resolveDrainOnClose(explicit: boolean | undefined, env?: NodeJS.ProcessEnv): boolean;
61
+ /**
62
+ * Minimal event surface of `http.ServerResponse` that the drain wait needs —
63
+ * structural so tests can pass a plain EventEmitter.
64
+ */
65
+ export interface DrainWaitable {
66
+ once(event: 'drain' | 'close', listener: () => void): unknown;
67
+ off(event: 'drain' | 'close', listener: () => void): unknown;
68
+ }
69
+ /**
70
+ * Wait out client-socket backpressure after `res.write()` returned false
71
+ * (dario#905). Settles on the first of:
72
+ *
73
+ * - `'drain'` on `res` — the client consumed its buffer; resume streaming.
74
+ * - `'close'` on `res` — the client connection is gone; the loop resumes
75
+ * and the gated writer / next read handles teardown.
76
+ * - `signal` aborts — the upstream AbortController fired (upstream
77
+ * timeout, client-close abort, SSE overflow).
78
+ *
79
+ * The abort arm is the load-bearing one. Without it, a client that stays
80
+ * connected but stops reading (dead peer behind an open TCP window, wedged
81
+ * consumer) parks the request handler on this promise forever: the upstream
82
+ * timeout still fires, but it only aborts the upstream fetch — which nothing
83
+ * is awaiting — so the handler's `finally` never runs and its concurrency
84
+ * slot leaks. Leaked slots accumulate until `active === maxConcurrent`, at
85
+ * which point every request 504s with `queue-timeout` while `/health` stays
86
+ * green (dario#905). With the abort arm, the wait resolves, the next
87
+ * `reader.read()` rejects on the aborted body, and normal teardown releases
88
+ * the slot.
89
+ *
90
+ * Resolves immediately when the signal is already aborted, and removes all
91
+ * three listeners on settle so repeated waits within one response never
92
+ * accumulate listeners on the shared signal.
93
+ */
94
+ export declare function waitForClientDrain(res: DrainWaitable, signal: AbortSignal): Promise<void>;
@@ -66,3 +66,45 @@ export function resolveDrainOnClose(explicit, env = process.env) {
66
66
  const v = (env.DARIO_DRAIN_ON_CLOSE ?? '').toLowerCase();
67
67
  return v === '1' || v === 'true' || v === 'yes';
68
68
  }
69
+ /**
70
+ * Wait out client-socket backpressure after `res.write()` returned false
71
+ * (dario#905). Settles on the first of:
72
+ *
73
+ * - `'drain'` on `res` — the client consumed its buffer; resume streaming.
74
+ * - `'close'` on `res` — the client connection is gone; the loop resumes
75
+ * and the gated writer / next read handles teardown.
76
+ * - `signal` aborts — the upstream AbortController fired (upstream
77
+ * timeout, client-close abort, SSE overflow).
78
+ *
79
+ * The abort arm is the load-bearing one. Without it, a client that stays
80
+ * connected but stops reading (dead peer behind an open TCP window, wedged
81
+ * consumer) parks the request handler on this promise forever: the upstream
82
+ * timeout still fires, but it only aborts the upstream fetch — which nothing
83
+ * is awaiting — so the handler's `finally` never runs and its concurrency
84
+ * slot leaks. Leaked slots accumulate until `active === maxConcurrent`, at
85
+ * which point every request 504s with `queue-timeout` while `/health` stays
86
+ * green (dario#905). With the abort arm, the wait resolves, the next
87
+ * `reader.read()` rejects on the aborted body, and normal teardown releases
88
+ * the slot.
89
+ *
90
+ * Resolves immediately when the signal is already aborted, and removes all
91
+ * three listeners on settle so repeated waits within one response never
92
+ * accumulate listeners on the shared signal.
93
+ */
94
+ export function waitForClientDrain(res, signal) {
95
+ return new Promise((resolve) => {
96
+ if (signal.aborted) {
97
+ resolve();
98
+ return;
99
+ }
100
+ const done = () => {
101
+ res.off('drain', done);
102
+ res.off('close', done);
103
+ signal.removeEventListener('abort', done);
104
+ resolve();
105
+ };
106
+ res.once('drain', done);
107
+ res.once('close', done);
108
+ signal.addEventListener('abort', done);
109
+ });
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.4.27",
3
+ "version": "5.4.28",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {