@askalf/dario 5.4.31 → 5.5.1

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.
@@ -45,7 +45,35 @@ export interface HealthStatusLike {
45
45
  queued: number;
46
46
  maxConcurrent: number;
47
47
  maxQueued: number;
48
+ /**
49
+ * Epoch ms since the queue has been at capacity with NO slot released
50
+ * (dario#905). Null when not at capacity; reset by any release, so
51
+ * sustained legitimate load never accumulates age here. See
52
+ * request-queue.ts for why turnover — not depth — is the wedge signal.
53
+ */
54
+ stalledSince?: number | null;
48
55
  };
56
+ /**
57
+ * Verdict from the opt-in serving probe (`/health?probe=1`), when the caller
58
+ * asked for one and was trusted enough to be given it. Absent otherwise —
59
+ * a plain /health never spends a token, so its absence means "not asked
60
+ * for", never "failed".
61
+ */
62
+ probe?: ServingProbeLike;
63
+ }
64
+ /**
65
+ * The subset of serving-probe.ts's ProbeResult that /health renders. Declared
66
+ * structurally rather than imported so this module stays free of the probe's
67
+ * network machinery and remains unit-testable as a pure function.
68
+ */
69
+ export interface ServingProbeLike {
70
+ ok: boolean;
71
+ reason: string;
72
+ checkedAt: number;
73
+ latencyMs: number;
74
+ model: string;
75
+ status?: number;
76
+ detail?: string;
49
77
  }
50
78
  export interface HealthResponse {
51
79
  httpStatus: number;
@@ -65,22 +93,87 @@ export interface PoolDerivedStatus {
65
93
  accounts: number;
66
94
  }
67
95
  export declare function derivePoolStatus(accounts: readonly PoolAccountStatusLike[], now: number, adminEnabled: boolean): PoolDerivedStatus;
68
- export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, includeInternal: boolean): HealthResponse;
96
+ export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, includeInternal: boolean, now?: number): HealthResponse;
97
+ /**
98
+ * Did this caller ask for a serving probe? (`/health?probe=1`, dario#905.)
99
+ *
100
+ * Accepts `probe=1` / `probe=true` / bare `probe`, rejects `probe=0` and
101
+ * `probe=false` — a monitor templating the flag from a boolean config should
102
+ * get the behaviour it wrote, not a probe on every poll because the parameter
103
+ * was merely present. Anything unparseable is treated as "not asked": the
104
+ * failure direction for a token-spending flag has to be off.
105
+ */
106
+ export declare function probeRequested(url: string | undefined): boolean;
107
+ /**
108
+ * Decide whether to actually RUN a serving probe for this caller (dario#905).
109
+ *
110
+ * Deliberately stricter than shouldDiscloseHealthInternals, because this is
111
+ * not a disclosure decision — it spends the operator's money.
112
+ *
113
+ * The disclosure gate grants access to a caller that proved a configured
114
+ * DARIO_API_KEY, wherever it came from. That is the right answer for reading a
115
+ * field. It is not the right answer for an action that bills per call: a
116
+ * leaked or shared key becomes a metered spend endpoint reachable from the
117
+ * public internet, and the probe's own cache means an attacker needs only one
118
+ * request per TTL to keep it running indefinitely.
119
+ *
120
+ * So the probe additionally refuses anything that arrived through the tunnel,
121
+ * whatever the disclosure gate concluded. This only ever DENIES — it cannot
122
+ * widen access.
123
+ *
124
+ * (An unkeyed proxy is handled a layer up: shouldDiscloseHealthInternals now
125
+ * requires `keyConfigured`, so vacuous authentication no longer reaches here
126
+ * at all. This gate does not depend on that fix — it would refuse the tunnel
127
+ * caller either way — but the two are the same defence at different depths.)
128
+ *
129
+ * Accepted trade-off: an operator who authenticates THROUGH the tunnel is also
130
+ * refused, and has to probe from beside the proxy instead. For a flag whose
131
+ * failure direction is "silently spends money", off is the right default.
132
+ */
133
+ export declare function shouldRunServingProbe(opts: {
134
+ requested: boolean;
135
+ discloseInternals: boolean;
136
+ viaCfRay: boolean;
137
+ }): boolean;
69
138
  /**
70
139
  * Decide whether a /health caller may see the OAuth internals (#642).
71
140
  *
72
141
  * /health is intentionally auth-free (docker healthchecks need it before a
73
142
  * key is configured), so we cannot simply gate on the API key. Trust model:
74
- * - authenticated (valid DARIO_API_KEY) -> internal (an internal caller)
143
+ * - PROVED a configured DARIO_API_KEY -> internal (an internal caller)
75
144
  * - came via the Cloudflare tunnel (cf-ray) -> public (world-reachable)
76
145
  * - otherwise bare loopback -> internal (docker HC / doctor)
77
146
  * - otherwise (LAN, other container, WAN) -> public
78
- * The cf-ray check is now only ever used to DENY (force public), never to
79
- * grant, so spoofing it cannot widen disclosure — the previous fail-open
80
- * direction is closed.
147
+ * The cf-ray check is only ever used to DENY (force public), never to grant,
148
+ * so spoofing it cannot widen disclosure.
149
+ *
150
+ * `keyConfigured` is load-bearing and is why `authenticated` alone is not
151
+ * enough. `authenticateRequest()` short-circuits to TRUE when no
152
+ * DARIO_API_KEY is set — a deliberate convenience, since the common setup is
153
+ * loopback-only and requiring a key there would break `dario doctor` and every
154
+ * docker healthcheck. But it means "authenticated" is VACUOUS on an unkeyed
155
+ * proxy: every caller satisfies it, the first branch returns before cf-ray is
156
+ * ever consulted, and an unkeyed dario published through a Cloudflare tunnel
157
+ * hands its OAuth countdown, request volume and refresh-failure count to
158
+ * anyone who asks. That is the #642 fail-open re-entering through a side door
159
+ * — #642 closed the spoofable-header direction, not this one.
160
+ *
161
+ * Requiring both means the auth branch can only be taken by a caller that
162
+ * actually presented the operator's secret. Unkeyed proxies fall through to
163
+ * the transport rules, where loopback is still trusted (healthchecks and
164
+ * doctor keep working, unchanged) and the tunnel is not.
165
+ *
166
+ * `keyConfigured` is a REQUIRED field rather than an optional with a default:
167
+ * for a security predicate, every call site should be forced to state it.
168
+ *
169
+ * The HTTP status (200/503) is unaffected either way, so uptime monitoring
170
+ * that keys on the status code sees no change from this.
81
171
  */
82
172
  export declare function shouldDiscloseHealthInternals(opts: {
173
+ /** Passed authenticateRequest — which is vacuously true when unkeyed. */
83
174
  authenticated: boolean;
175
+ /** Whether a DARIO_API_KEY exists at all, i.e. whether `authenticated` means anything. */
176
+ keyConfigured: boolean;
84
177
  loopback: boolean;
85
178
  viaCfRay: boolean;
86
179
  }): boolean;
@@ -59,10 +59,28 @@ export function derivePoolStatus(accounts, now, adminEnabled) {
59
59
  expiresIn: formatMsLeft(earliest - now),
60
60
  };
61
61
  }
62
- export function buildHealthResponse(s, requestCount, includeInternal) {
63
- const dead = s.status === 'broken' ||
62
+ /**
63
+ * Render `stalledSince` as an elapsed duration alongside the raw stamp. The
64
+ * stamp alone forces every consumer to subtract against its own clock, which
65
+ * is exactly the arithmetic a shell-based healthcheck can't do — and #905's
66
+ * reporter was writing his monitor in bash.
67
+ */
68
+ function withStalledFor(q, now) {
69
+ const { stalledSince, ...rest } = q;
70
+ if (stalledSince === null || stalledSince === undefined)
71
+ return { ...rest, stalledSince: null };
72
+ return { ...rest, stalledSince, stalledForMs: Math.max(0, now - stalledSince) };
73
+ }
74
+ export function buildHealthResponse(s, requestCount, includeInternal, now = Date.now()) {
75
+ const structurallyDead = s.status === 'broken' ||
64
76
  s.status === 'none' ||
65
77
  (s.status === 'expired' && s.canRefresh === false);
78
+ // A failed round-trip is authoritative over a clean structural read: the
79
+ // whole point of the probe (dario#905) is the state where local inspection
80
+ // says healthy and every real request fails. When one was run and it came
81
+ // back false, /health must say degraded — that is what makes an existing
82
+ // status-code-only uptime monitor start seeing the outage it used to miss.
83
+ const dead = structurallyDead || s.probe?.ok === false;
66
84
  const httpStatus = dead ? 503 : 200;
67
85
  const liveness = { status: dead ? 'degraded' : 'ok' };
68
86
  // Only trusted callers (authenticated, or bare loopback not via the CF
@@ -82,27 +100,102 @@ export function buildHealthResponse(s, requestCount, includeInternal) {
82
100
  expiresIn: s.expiresIn,
83
101
  requests: requestCount,
84
102
  ...(s.sessions ? { sessions: s.sessions } : {}),
85
- ...(s.queue ? { queue: s.queue } : {}),
103
+ ...(s.queue ? { queue: withStalledFor(s.queue, now) } : {}),
104
+ ...(s.probe ? { probe: { ...s.probe, ageMs: Math.max(0, now - s.probe.checkedAt) } } : {}),
86
105
  ...(s.refreshFailures ? { refreshFailures: s.refreshFailures } : {}),
87
106
  }
88
107
  : liveness;
89
108
  return { httpStatus, body };
90
109
  }
110
+ /**
111
+ * Did this caller ask for a serving probe? (`/health?probe=1`, dario#905.)
112
+ *
113
+ * Accepts `probe=1` / `probe=true` / bare `probe`, rejects `probe=0` and
114
+ * `probe=false` — a monitor templating the flag from a boolean config should
115
+ * get the behaviour it wrote, not a probe on every poll because the parameter
116
+ * was merely present. Anything unparseable is treated as "not asked": the
117
+ * failure direction for a token-spending flag has to be off.
118
+ */
119
+ export function probeRequested(url) {
120
+ const q = url?.indexOf('?') ?? -1;
121
+ if (q < 0)
122
+ return false;
123
+ const v = new URLSearchParams(url.slice(q + 1)).get('probe');
124
+ if (v === null)
125
+ return false;
126
+ if (v === '')
127
+ return true; // bare `?probe`
128
+ return v === '1' || v.toLowerCase() === 'true';
129
+ }
130
+ /**
131
+ * Decide whether to actually RUN a serving probe for this caller (dario#905).
132
+ *
133
+ * Deliberately stricter than shouldDiscloseHealthInternals, because this is
134
+ * not a disclosure decision — it spends the operator's money.
135
+ *
136
+ * The disclosure gate grants access to a caller that proved a configured
137
+ * DARIO_API_KEY, wherever it came from. That is the right answer for reading a
138
+ * field. It is not the right answer for an action that bills per call: a
139
+ * leaked or shared key becomes a metered spend endpoint reachable from the
140
+ * public internet, and the probe's own cache means an attacker needs only one
141
+ * request per TTL to keep it running indefinitely.
142
+ *
143
+ * So the probe additionally refuses anything that arrived through the tunnel,
144
+ * whatever the disclosure gate concluded. This only ever DENIES — it cannot
145
+ * widen access.
146
+ *
147
+ * (An unkeyed proxy is handled a layer up: shouldDiscloseHealthInternals now
148
+ * requires `keyConfigured`, so vacuous authentication no longer reaches here
149
+ * at all. This gate does not depend on that fix — it would refuse the tunnel
150
+ * caller either way — but the two are the same defence at different depths.)
151
+ *
152
+ * Accepted trade-off: an operator who authenticates THROUGH the tunnel is also
153
+ * refused, and has to probe from beside the proxy instead. For a flag whose
154
+ * failure direction is "silently spends money", off is the right default.
155
+ */
156
+ export function shouldRunServingProbe(opts) {
157
+ if (!opts.requested)
158
+ return false;
159
+ if (opts.viaCfRay)
160
+ return false;
161
+ return opts.discloseInternals;
162
+ }
91
163
  /**
92
164
  * Decide whether a /health caller may see the OAuth internals (#642).
93
165
  *
94
166
  * /health is intentionally auth-free (docker healthchecks need it before a
95
167
  * key is configured), so we cannot simply gate on the API key. Trust model:
96
- * - authenticated (valid DARIO_API_KEY) -> internal (an internal caller)
168
+ * - PROVED a configured DARIO_API_KEY -> internal (an internal caller)
97
169
  * - came via the Cloudflare tunnel (cf-ray) -> public (world-reachable)
98
170
  * - otherwise bare loopback -> internal (docker HC / doctor)
99
171
  * - otherwise (LAN, other container, WAN) -> public
100
- * The cf-ray check is now only ever used to DENY (force public), never to
101
- * grant, so spoofing it cannot widen disclosure — the previous fail-open
102
- * direction is closed.
172
+ * The cf-ray check is only ever used to DENY (force public), never to grant,
173
+ * so spoofing it cannot widen disclosure.
174
+ *
175
+ * `keyConfigured` is load-bearing and is why `authenticated` alone is not
176
+ * enough. `authenticateRequest()` short-circuits to TRUE when no
177
+ * DARIO_API_KEY is set — a deliberate convenience, since the common setup is
178
+ * loopback-only and requiring a key there would break `dario doctor` and every
179
+ * docker healthcheck. But it means "authenticated" is VACUOUS on an unkeyed
180
+ * proxy: every caller satisfies it, the first branch returns before cf-ray is
181
+ * ever consulted, and an unkeyed dario published through a Cloudflare tunnel
182
+ * hands its OAuth countdown, request volume and refresh-failure count to
183
+ * anyone who asks. That is the #642 fail-open re-entering through a side door
184
+ * — #642 closed the spoofable-header direction, not this one.
185
+ *
186
+ * Requiring both means the auth branch can only be taken by a caller that
187
+ * actually presented the operator's secret. Unkeyed proxies fall through to
188
+ * the transport rules, where loopback is still trusted (healthchecks and
189
+ * doctor keep working, unchanged) and the tunnel is not.
190
+ *
191
+ * `keyConfigured` is a REQUIRED field rather than an optional with a default:
192
+ * for a security predicate, every call site should be forced to state it.
193
+ *
194
+ * The HTTP status (200/503) is unaffected either way, so uptime monitoring
195
+ * that keys on the status code sees no change from this.
103
196
  */
104
197
  export function shouldDiscloseHealthInternals(opts) {
105
- if (opts.authenticated)
198
+ if (opts.authenticated && opts.keyConfigured)
106
199
  return true;
107
200
  if (opts.viaCfRay)
108
201
  return false;
package/dist/proxy.js CHANGED
@@ -6,7 +6,8 @@ import { homedir } from 'node:os';
6
6
  import { setDefaultResultOrder } from 'node:dns';
7
7
  import { arch, platform } from 'node:process';
8
8
  import { getAccessToken, getStatus, ignoreCcCredentials } from './oauth.js';
9
- import { buildHealthResponse, derivePoolStatus, shouldDiscloseHealthInternals } from './health-response.js';
9
+ import { buildHealthResponse, derivePoolStatus, probeRequested, shouldDiscloseHealthInternals, shouldRunServingProbe } from './health-response.js';
10
+ import { getServingProbe } from './serving-probe.js';
10
11
  import { darioVersion } from './version.js';
11
12
  import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
12
13
  import { stampCch, hasCchSeed } from './cch.js';
@@ -1697,14 +1698,39 @@ export async function startProxy(opts = {}) {
1697
1698
  // key on the presence of the client-suppliable `cf-ray` header and failed
1698
1699
  // OPEN — a direct non-tunnel caller omits it and got the full internal
1699
1700
  // view. Now: authenticated, OR bare loopback that did not arrive via CF.
1701
+ const viaCfRay = req.headers['cf-ray'] !== undefined;
1700
1702
  const includeInternal = shouldDiscloseHealthInternals({
1701
1703
  authenticated: authenticateRequest(req.headers, apiKeyBuf),
1704
+ // Without this, `authenticated` is vacuously true on an unkeyed proxy
1705
+ // and the tunnel check below is never reached — see the gate's docs.
1706
+ keyConfigured: apiKeyBuf !== null,
1702
1707
  loopback: isLoopbackAddr(req.socket?.remoteAddress),
1703
- viaCfRay: req.headers['cf-ray'] !== undefined,
1708
+ viaCfRay,
1704
1709
  });
1710
+ // Opt-in serving probe (#905). A real upstream round-trip costs a real
1711
+ // (tiny) billed request, so it runs only when explicitly asked for and
1712
+ // only for callers that clear shouldRunServingProbe — which is stricter
1713
+ // than the disclosure gate on purpose (see its docstring: an unkeyed
1714
+ // dario authenticates everyone, which must not turn `?probe=1` into a
1715
+ // spend button for the public internet). Cached and single-flighted
1716
+ // inside getServingProbe, so a monitor polling every second still costs
1717
+ // at most one probe per TTL.
1718
+ const wantsProbe = shouldRunServingProbe({
1719
+ requested: probeRequested(req.url),
1720
+ discloseInternals: includeInternal,
1721
+ viaCfRay,
1722
+ });
1723
+ const probe = wantsProbe
1724
+ ? await getServingProbe({
1725
+ fetchImpl: upstreamFetch,
1726
+ getToken: catalogDeps.getToken,
1727
+ upstreamApiKey: upstreamApiKey || undefined,
1728
+ })
1729
+ : undefined;
1705
1730
  const { httpStatus, body } = buildHealthResponse({
1706
1731
  ...s,
1707
1732
  version: darioVersion(),
1733
+ ...(probe ? { probe } : {}),
1708
1734
  // pool.size === 0 is single-account mode (session-id registry drives
1709
1735
  // the SESSION_ID slot); a loaded pool routes via sticky bindings.
1710
1736
  sessions: pool.size === 0
@@ -28,6 +28,31 @@ export interface QueueState {
28
28
  maxConcurrent: number;
29
29
  maxQueued: number;
30
30
  }
31
+ /**
32
+ * QueueState plus the one derived field a monitor actually needs (dario#905).
33
+ *
34
+ * #910 put `active` / `queued` on /health so slot exhaustion stopped being
35
+ * invisible. But a raw sample cannot distinguish the 14h wedge from a healthy
36
+ * one-second burst — both read `active === maxConcurrent, queued > 0`. Polling
37
+ * fast enough to tell them apart is the monitor's problem, and it shouldn't be.
38
+ *
39
+ * The distinguishing signal is TURNOVER, not depth. A busy dario runs at its
40
+ * cap with a backlog all day and is perfectly healthy, because slots keep
41
+ * being released. The #905 wedge held `active` at `maxConcurrent` for hours
42
+ * with no release at all.
43
+ *
44
+ * So `stalledSince` is the epoch ms since which the queue has been at capacity
45
+ * with requests waiting AND NOT ONE SLOT HAS BEEN RELEASED. Any release resets
46
+ * it. Null when the queue isn't at capacity. A non-null value older than a
47
+ * request could plausibly take means slots are not turning over — one sample
48
+ * is enough to see it, and sustained legitimate load never trips it.
49
+ *
50
+ * This is also why the serving probe deliberately doesn't take a slot: the
51
+ * concurrency axis is covered here, for free and without false positives.
52
+ */
53
+ export interface QueueSnapshot extends QueueState {
54
+ stalledSince: number | null;
55
+ }
31
56
  export type AdmitDecision = {
32
57
  action: 'admit';
33
58
  } | {
@@ -60,6 +85,8 @@ export interface RequestQueueOptions {
60
85
  * test is waiting for never arrives.
61
86
  */
62
87
  unrefTimers?: boolean;
88
+ /** Clock source for `saturatedSince`. Injectable so tests need no timers. */
89
+ now?: () => number;
63
90
  }
64
91
  export declare const DEFAULT_MAX_CONCURRENT = 10;
65
92
  export declare const DEFAULT_MAX_QUEUED = 128;
@@ -71,7 +98,19 @@ export declare class RequestQueue {
71
98
  readonly unrefTimers: boolean;
72
99
  private active;
73
100
  private queue;
101
+ private readonly now;
102
+ private stalledSince;
74
103
  constructor(opts?: RequestQueueOptions);
104
+ /**
105
+ * Re-evaluate the stall stamp. Called after every state change, so it marks
106
+ * when the stall BEGAN rather than when it was last observed — a caller that
107
+ * never polls still reads an accurate duration.
108
+ *
109
+ * Arrivals must NOT refresh the stamp: under a steady arrival rate that
110
+ * would reset the clock continuously and hide a permanent wedge. Only
111
+ * `release()` refreshes it, by clearing first (see there).
112
+ */
113
+ private updateStall;
75
114
  /**
76
115
  * Acquire a concurrency slot. Resolves when admitted; throws
77
116
  * `QueueFullError` when the queue is at its `maxQueued` cap, throws
@@ -81,6 +120,6 @@ export declare class RequestQueue {
81
120
  acquire(): Promise<void>;
82
121
  /** Release a slot. The next queued entry (if any) is admitted in FIFO order. */
83
122
  release(): void;
84
- /** Snapshot of queue state — exposed for /analytics + tests. */
85
- snapshot(): QueueState;
123
+ /** Snapshot of queue state — exposed for /health + /analytics + tests. */
124
+ snapshot(): QueueSnapshot;
86
125
  }
@@ -50,11 +50,32 @@ export class RequestQueue {
50
50
  unrefTimers;
51
51
  active = 0;
52
52
  queue = [];
53
+ now;
54
+ stalledSince = null;
53
55
  constructor(opts = {}) {
54
56
  this.maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
55
57
  this.maxQueued = opts.maxQueued ?? DEFAULT_MAX_QUEUED;
56
58
  this.queueTimeoutMs = opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
57
59
  this.unrefTimers = opts.unrefTimers ?? true;
60
+ this.now = opts.now ?? Date.now;
61
+ }
62
+ /**
63
+ * Re-evaluate the stall stamp. Called after every state change, so it marks
64
+ * when the stall BEGAN rather than when it was last observed — a caller that
65
+ * never polls still reads an accurate duration.
66
+ *
67
+ * Arrivals must NOT refresh the stamp: under a steady arrival rate that
68
+ * would reset the clock continuously and hide a permanent wedge. Only
69
+ * `release()` refreshes it, by clearing first (see there).
70
+ */
71
+ updateStall() {
72
+ const atCapacity = this.active >= this.maxConcurrent && this.queue.length > 0;
73
+ if (!atCapacity) {
74
+ this.stalledSince = null;
75
+ return;
76
+ }
77
+ if (this.stalledSince === null)
78
+ this.stalledSince = this.now();
58
79
  }
59
80
  /**
60
81
  * Acquire a concurrency slot. Resolves when admitted; throws
@@ -66,17 +87,19 @@ export class RequestQueue {
66
87
  const decision = decideAdmit(this.snapshot());
67
88
  if (decision.action === 'admit') {
68
89
  this.active++;
90
+ this.updateStall();
69
91
  return;
70
92
  }
71
93
  if (decision.action === 'reject') {
72
94
  throw new QueueFullError();
73
95
  }
74
96
  return new Promise((resolve, reject) => {
75
- const enqueuedAt = Date.now();
97
+ const enqueuedAt = this.now();
76
98
  const timeoutHandle = setTimeout(() => {
77
99
  const idx = this.queue.indexOf(entry);
78
100
  if (idx >= 0) {
79
101
  this.queue.splice(idx, 1);
102
+ this.updateStall();
80
103
  reject(new QueueTimeoutError());
81
104
  }
82
105
  }, this.queueTimeoutMs);
@@ -87,6 +110,7 @@ export class RequestQueue {
87
110
  timeoutHandle.unref?.();
88
111
  const entry = { resolve, reject, enqueuedAt, timeoutHandle };
89
112
  this.queue.push(entry);
113
+ this.updateStall();
90
114
  });
91
115
  }
92
116
  /** Release a slot. The next queued entry (if any) is admitted in FIFO order. */
@@ -99,14 +123,22 @@ export class RequestQueue {
99
123
  this.active++;
100
124
  next.resolve();
101
125
  }
126
+ // A release IS turnover — the thing whose absence defines the wedge — so
127
+ // clear the stamp unconditionally before re-evaluating. A queue that is
128
+ // still at capacity immediately starts a FRESH stall window, which is why
129
+ // a saturated-but-flowing dario never accumulates age here while a
130
+ // genuinely wedged one does.
131
+ this.stalledSince = null;
132
+ this.updateStall();
102
133
  }
103
- /** Snapshot of queue state — exposed for /analytics + tests. */
134
+ /** Snapshot of queue state — exposed for /health + /analytics + tests. */
104
135
  snapshot() {
105
136
  return {
106
137
  active: this.active,
107
138
  queued: this.queue.length,
108
139
  maxConcurrent: this.maxConcurrent,
109
140
  maxQueued: this.maxQueued,
141
+ stalledSince: this.stalledSince,
110
142
  };
111
143
  }
112
144
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * serving-probe.ts — the "can dario actually serve a request?" check.
3
+ *
4
+ * WHY THIS EXISTS (dario#905, dario#921)
5
+ *
6
+ * dario's existing health surfaces are both STRUCTURAL — they inspect state,
7
+ * they never prove anything end-to-end:
8
+ *
9
+ * /livez the HTTP server is accepting connections. Always 200.
10
+ * /health credentials/pool look sane: token not expired, refresh not broken,
11
+ * accounts not all in auth-cooldown, queue snapshot attached.
12
+ *
13
+ * Neither can catch the failure class that actually took a user down for ~14h
14
+ * across four episodes (#905): dario looking perfectly healthy from the outside
15
+ * while every real request failed. The reporter's remediation was an external
16
+ * watchdog that sends a real inference call and restarts the service when it
17
+ * doesn't come back — i.e. the user had to write the liveness check dario
18
+ * should own. This module is that check, brought in-house.
19
+ *
20
+ * It also covers a class /health provably cannot see: an account whose token is
21
+ * unexpired and refreshable (so `status: healthy`) but whose accountUuid has
22
+ * drifted, so upstream 401s every request. Structural inspection says fine;
23
+ * only a round-trip says otherwise.
24
+ *
25
+ * WHAT IT PROVES, AND WHAT IT DELIBERATELY DOES NOT
26
+ *
27
+ * The probe is a minimal `POST /v1/messages` (max_tokens 1) sent DIRECTLY to
28
+ * api.anthropic.com with the same auth the request path uses — pool bearer, or
29
+ * `x-api-key` in upstream-API-key mode. That proves: a token can be acquired,
30
+ * the network path is up, and upstream accepts our credential.
31
+ *
32
+ * It deliberately does NOT go through dario's own proxy path:
33
+ * - No recursion, no self-deadlock, no port/auth assumptions.
34
+ * - It must not take a concurrency slot. A probe that queues behind real
35
+ * traffic would report "unhealthy" during a legitimate burst and hand a
36
+ * watchdog a reason to restart a busy-but-fine dario. Slot exhaustion is
37
+ * covered instead by `queue.saturatedSince` (request-queue.ts), which is
38
+ * free, needs no tokens, and cannot false-positive on a short burst.
39
+ *
40
+ * So: the probe covers the credential/network axis, the queue snapshot covers
41
+ * the concurrency axis. Neither claims to cover the transform path.
42
+ *
43
+ * COST AND EXPOSURE
44
+ *
45
+ * A probe is a real billed request. Three guards, all load-bearing:
46
+ * 1. OPT-IN. Only runs when a caller explicitly asks (`/health?probe=1`).
47
+ * A plain /health never spends a token — existing docker healthchecks and
48
+ * uptime monitors keep their current cost profile, which is zero.
49
+ * 2. TRUSTED CALLERS ONLY. proxy.ts honours `?probe=1` only for callers that
50
+ * already pass shouldDiscloseHealthInternals. A world-readable /health
51
+ * behind a Cloudflare tunnel bypass must not be a button the internet can
52
+ * press to spend the operator's tokens.
53
+ * 3. CACHED + SINGLE-FLIGHTED. Results are reused for `ttlMs` (default 60s)
54
+ * and concurrent callers share one in-flight request, so a monitor polling
55
+ * every second still costs at most one probe per minute.
56
+ *
57
+ * VERDICT SEMANTICS — why 429 is NOT a failure
58
+ *
59
+ * `ok` answers "would a restart or an alert help?", not "did the call return
60
+ * 200". Rate-limited (429) and upstream-overloaded (529) are healthy states:
61
+ * dario is working, the answer is legitimately "not right now", and a watchdog
62
+ * that restarts on them just thrashes. That is the same lesson /livez already
63
+ * encodes in proxy.ts — a shared-refresh-family outage once had dario restart
64
+ * -looping for 4h+ because the healthcheck keyed on a condition a restart
65
+ * cannot fix. Auth rejection, 5xx, network failure and timeout DO set ok=false.
66
+ */
67
+ /** Cheapest family, and the one doctor's own probe already uses. */
68
+ export declare const DEFAULT_PROBE_MODEL = "claude-haiku-4-5";
69
+ export declare const DEFAULT_PROBE_TTL_MS = 60000;
70
+ export declare const DEFAULT_PROBE_TIMEOUT_MS = 15000;
71
+ /**
72
+ * Why the round-trip ended the way it did. Carried on both verdicts so an
73
+ * operator reading a green probe can still tell "served" from "rate-limited".
74
+ */
75
+ export type ProbeReason = 'served' | 'rate-limited' | 'upstream-overloaded' | 'no-token' | 'auth-rejected' | 'upstream-error' | 'timeout' | 'network-error';
76
+ export interface ProbeResult {
77
+ /** False only for conditions where dario is the problem — see module header. */
78
+ ok: boolean;
79
+ reason: ProbeReason;
80
+ /** Epoch ms the round-trip completed. */
81
+ checkedAt: number;
82
+ /** Wall time of the round-trip, including token acquisition. */
83
+ latencyMs: number;
84
+ model: string;
85
+ /** Upstream HTTP status, when upstream answered at all. */
86
+ status?: number;
87
+ /** Short failure detail. Never carries a token or a raw body. */
88
+ detail?: string;
89
+ }
90
+ export interface ProbeDeps {
91
+ fetchImpl?: typeof fetch;
92
+ /** OAuth bearer source. Ignored when upstreamApiKey is set. */
93
+ getToken?: () => Promise<string>;
94
+ /** Per-token API mode — forwarded as x-api-key, mirroring request-path auth. */
95
+ upstreamApiKey?: string;
96
+ now?: () => number;
97
+ model?: string;
98
+ timeoutMs?: number;
99
+ ttlMs?: number;
100
+ }
101
+ /**
102
+ * Map an upstream HTTP status onto a verdict. Pure, so the whole policy
103
+ * ("which statuses mean dario is broken") is testable without a network.
104
+ */
105
+ export declare function classifyProbeStatus(status: number): {
106
+ ok: boolean;
107
+ reason: ProbeReason;
108
+ };
109
+ /**
110
+ * The cached verdict, refreshing it when stale. Never throws — a health
111
+ * surface that can 500 is worse than useless, so every failure path becomes a
112
+ * `ok: false` verdict with a reason instead of an exception.
113
+ *
114
+ * Concurrent callers past the TTL share one in-flight probe; the loser of the
115
+ * race gets the same result rather than sending a second billed request.
116
+ */
117
+ export declare function getServingProbe(deps?: ProbeDeps): Promise<ProbeResult>;
118
+ /** Age of the cached verdict, for the `ageMs` field callers see. */
119
+ export declare function probeAgeMs(result: ProbeResult, now: number): number;
120
+ export declare function _resetServingProbeForTest(): void;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * serving-probe.ts — the "can dario actually serve a request?" check.
3
+ *
4
+ * WHY THIS EXISTS (dario#905, dario#921)
5
+ *
6
+ * dario's existing health surfaces are both STRUCTURAL — they inspect state,
7
+ * they never prove anything end-to-end:
8
+ *
9
+ * /livez the HTTP server is accepting connections. Always 200.
10
+ * /health credentials/pool look sane: token not expired, refresh not broken,
11
+ * accounts not all in auth-cooldown, queue snapshot attached.
12
+ *
13
+ * Neither can catch the failure class that actually took a user down for ~14h
14
+ * across four episodes (#905): dario looking perfectly healthy from the outside
15
+ * while every real request failed. The reporter's remediation was an external
16
+ * watchdog that sends a real inference call and restarts the service when it
17
+ * doesn't come back — i.e. the user had to write the liveness check dario
18
+ * should own. This module is that check, brought in-house.
19
+ *
20
+ * It also covers a class /health provably cannot see: an account whose token is
21
+ * unexpired and refreshable (so `status: healthy`) but whose accountUuid has
22
+ * drifted, so upstream 401s every request. Structural inspection says fine;
23
+ * only a round-trip says otherwise.
24
+ *
25
+ * WHAT IT PROVES, AND WHAT IT DELIBERATELY DOES NOT
26
+ *
27
+ * The probe is a minimal `POST /v1/messages` (max_tokens 1) sent DIRECTLY to
28
+ * api.anthropic.com with the same auth the request path uses — pool bearer, or
29
+ * `x-api-key` in upstream-API-key mode. That proves: a token can be acquired,
30
+ * the network path is up, and upstream accepts our credential.
31
+ *
32
+ * It deliberately does NOT go through dario's own proxy path:
33
+ * - No recursion, no self-deadlock, no port/auth assumptions.
34
+ * - It must not take a concurrency slot. A probe that queues behind real
35
+ * traffic would report "unhealthy" during a legitimate burst and hand a
36
+ * watchdog a reason to restart a busy-but-fine dario. Slot exhaustion is
37
+ * covered instead by `queue.saturatedSince` (request-queue.ts), which is
38
+ * free, needs no tokens, and cannot false-positive on a short burst.
39
+ *
40
+ * So: the probe covers the credential/network axis, the queue snapshot covers
41
+ * the concurrency axis. Neither claims to cover the transform path.
42
+ *
43
+ * COST AND EXPOSURE
44
+ *
45
+ * A probe is a real billed request. Three guards, all load-bearing:
46
+ * 1. OPT-IN. Only runs when a caller explicitly asks (`/health?probe=1`).
47
+ * A plain /health never spends a token — existing docker healthchecks and
48
+ * uptime monitors keep their current cost profile, which is zero.
49
+ * 2. TRUSTED CALLERS ONLY. proxy.ts honours `?probe=1` only for callers that
50
+ * already pass shouldDiscloseHealthInternals. A world-readable /health
51
+ * behind a Cloudflare tunnel bypass must not be a button the internet can
52
+ * press to spend the operator's tokens.
53
+ * 3. CACHED + SINGLE-FLIGHTED. Results are reused for `ttlMs` (default 60s)
54
+ * and concurrent callers share one in-flight request, so a monitor polling
55
+ * every second still costs at most one probe per minute.
56
+ *
57
+ * VERDICT SEMANTICS — why 429 is NOT a failure
58
+ *
59
+ * `ok` answers "would a restart or an alert help?", not "did the call return
60
+ * 200". Rate-limited (429) and upstream-overloaded (529) are healthy states:
61
+ * dario is working, the answer is legitimately "not right now", and a watchdog
62
+ * that restarts on them just thrashes. That is the same lesson /livez already
63
+ * encodes in proxy.ts — a shared-refresh-family outage once had dario restart
64
+ * -looping for 4h+ because the healthcheck keyed on a condition a restart
65
+ * cannot fix. Auth rejection, 5xx, network failure and timeout DO set ok=false.
66
+ */
67
+ const ANTHROPIC_MESSAGES = 'https://api.anthropic.com/v1/messages';
68
+ const ANTHROPIC_VERSION = '2023-06-01';
69
+ const OAUTH_BETA = 'oauth-2025-04-20';
70
+ /** Cheapest family, and the one doctor's own probe already uses. */
71
+ export const DEFAULT_PROBE_MODEL = 'claude-haiku-4-5';
72
+ export const DEFAULT_PROBE_TTL_MS = 60_000;
73
+ export const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
74
+ let cache = null;
75
+ let inflight = null;
76
+ function envInt(name, dflt) {
77
+ const v = Number(process.env[name]);
78
+ return Number.isFinite(v) && v > 0 ? v : dflt;
79
+ }
80
+ /**
81
+ * Map an upstream HTTP status onto a verdict. Pure, so the whole policy
82
+ * ("which statuses mean dario is broken") is testable without a network.
83
+ */
84
+ export function classifyProbeStatus(status) {
85
+ if (status >= 200 && status < 300)
86
+ return { ok: true, reason: 'served' };
87
+ // Serving fine, just throttled — restarting cannot help, so do not fail.
88
+ if (status === 429)
89
+ return { ok: true, reason: 'rate-limited' };
90
+ if (status === 529)
91
+ return { ok: true, reason: 'upstream-overloaded' };
92
+ // The credential is being rejected even though local state looks valid.
93
+ // This is the accountUuid-drift signature /health structurally cannot see.
94
+ if (status === 401 || status === 403)
95
+ return { ok: false, reason: 'auth-rejected' };
96
+ return { ok: false, reason: 'upstream-error' };
97
+ }
98
+ async function runProbe(deps) {
99
+ const f = deps.fetchImpl ?? fetch;
100
+ const now = deps.now ?? Date.now;
101
+ const model = deps.model ?? process.env.DARIO_PROBE_MODEL ?? DEFAULT_PROBE_MODEL;
102
+ const timeoutMs = deps.timeoutMs ?? envInt('DARIO_PROBE_TIMEOUT_MS', DEFAULT_PROBE_TIMEOUT_MS);
103
+ const startedAt = now();
104
+ // Arm the deadline BEFORE token acquisition so a wedged refresh is bounded
105
+ // too, not just the fetch — the same trap model-catalog.ts hit in #642: a
106
+ // getToken() that never settles would leave `inflight` non-null forever and
107
+ // permanently wedge every future probe on a stale cached verdict.
108
+ const ctl = new AbortController();
109
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
110
+ const finish = (r) => ({
111
+ ...r,
112
+ model,
113
+ checkedAt: now(),
114
+ latencyMs: now() - startedAt,
115
+ });
116
+ try {
117
+ const headers = {
118
+ 'content-type': 'application/json',
119
+ 'anthropic-version': ANTHROPIC_VERSION,
120
+ };
121
+ if (deps.upstreamApiKey) {
122
+ headers['x-api-key'] = deps.upstreamApiKey;
123
+ }
124
+ else {
125
+ if (!deps.getToken)
126
+ return finish({ ok: false, reason: 'no-token', detail: 'no token source configured' });
127
+ let token;
128
+ try {
129
+ token = await Promise.race([
130
+ deps.getToken(),
131
+ new Promise((_, rej) => {
132
+ ctl.signal.addEventListener('abort', () => rej(new Error('token acquisition timed out')), { once: true });
133
+ }),
134
+ ]);
135
+ }
136
+ catch (err) {
137
+ // An empty pool lands here — the message already says so (pool.ts /
138
+ // catalogDeps phrase it for the operator), so surface it verbatim.
139
+ return finish({ ok: false, reason: 'no-token', detail: errText(err) });
140
+ }
141
+ headers['authorization'] = `Bearer ${token}`;
142
+ headers['anthropic-beta'] = OAUTH_BETA;
143
+ }
144
+ const res = await f(ANTHROPIC_MESSAGES, {
145
+ method: 'POST',
146
+ headers,
147
+ body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }),
148
+ signal: ctl.signal,
149
+ });
150
+ // Drain so the socket is released back to the agent pool. The body is
151
+ // never inspected: a 200 is the whole signal, and the content could carry
152
+ // model output we have no reason to log.
153
+ await res.text().catch(() => '');
154
+ const verdict = classifyProbeStatus(res.status);
155
+ return finish({ ...verdict, status: res.status });
156
+ }
157
+ catch (err) {
158
+ const aborted = ctl.signal.aborted || err?.name === 'AbortError';
159
+ return finish({
160
+ ok: false,
161
+ reason: aborted ? 'timeout' : 'network-error',
162
+ detail: aborted ? `no upstream response within ${timeoutMs}ms` : errText(err),
163
+ });
164
+ }
165
+ finally {
166
+ clearTimeout(timer);
167
+ }
168
+ }
169
+ function errText(err) {
170
+ return err instanceof Error ? err.message : String(err);
171
+ }
172
+ /**
173
+ * The cached verdict, refreshing it when stale. Never throws — a health
174
+ * surface that can 500 is worse than useless, so every failure path becomes a
175
+ * `ok: false` verdict with a reason instead of an exception.
176
+ *
177
+ * Concurrent callers past the TTL share one in-flight probe; the loser of the
178
+ * race gets the same result rather than sending a second billed request.
179
+ */
180
+ export async function getServingProbe(deps = {}) {
181
+ const now = (deps.now ?? Date.now)();
182
+ const ttl = deps.ttlMs ?? envInt('DARIO_PROBE_TTL_MS', DEFAULT_PROBE_TTL_MS);
183
+ if (cache !== null && now - cache.checkedAt < ttl)
184
+ return cache;
185
+ if (inflight !== null)
186
+ return inflight;
187
+ inflight = runProbe(deps)
188
+ .then((r) => {
189
+ cache = r;
190
+ return r;
191
+ })
192
+ .finally(() => {
193
+ inflight = null;
194
+ });
195
+ return inflight;
196
+ }
197
+ /** Age of the cached verdict, for the `ageMs` field callers see. */
198
+ export function probeAgeMs(result, now) {
199
+ return Math.max(0, now - result.checkedAt);
200
+ }
201
+ export function _resetServingProbeForTest() {
202
+ cache = null;
203
+ inflight = null;
204
+ }
package/docs/usage.md CHANGED
@@ -121,3 +121,80 @@ const backends = await listBackends();
121
121
  ```bash
122
122
  curl http://localhost:3456/health
123
123
  ```
124
+
125
+ dario has three health surfaces, and they answer different questions:
126
+
127
+ | Endpoint | Answers | Costs |
128
+ |---|---|---|
129
+ | `GET /livez` | Is the HTTP server accepting connections? Always 200. | nothing |
130
+ | `GET /health` | Do credentials and the pool look serviceable? 503 when not. | nothing |
131
+ | `GET /health?probe=1` | Did a real request to Anthropic just succeed? | one tiny billed request per TTL |
132
+
133
+ `/health` inspects state; it does not prove anything end-to-end. Adding
134
+ `?probe=1` sends a real `max_tokens: 1` request upstream and folds the verdict
135
+ into the response, so a proxy whose credentials look fine but whose requests all
136
+ fail returns 503 instead of `ok`:
137
+
138
+ ```json
139
+ {
140
+ "status": "degraded",
141
+ "oauth": "valid",
142
+ "probe": { "ok": false, "reason": "auth-rejected", "status": 401,
143
+ "latencyMs": 233, "ageMs": 4812, "model": "claude-haiku-4-5" },
144
+ "queue": { "active": 10, "queued": 4, "maxConcurrent": 10,
145
+ "stalledSince": 1754790000000, "stalledForMs": 28800000 }
146
+ }
147
+ ```
148
+
149
+ Notes that matter in production:
150
+
151
+ - **The probe is opt-in and never runs on a plain `/health`.** Existing docker
152
+ healthchecks and uptime monitors keep costing nothing.
153
+ - **Only trusted callers can trigger it** — and never a caller that arrived
154
+ through a Cloudflare tunnel, even an authenticated one. A `/health` reachable
155
+ from the internet is not a button for spending tokens, and the probe's own
156
+ cache means one request per TTL would be enough to keep it running.
157
+ - **Results are cached and single-flighted** (`DARIO_PROBE_TTL_MS`, default
158
+ 60000), so polling every second still costs at most one probe per minute.
159
+ - **A rate-limited or overloaded upstream is not an outage.** 429 and 529 keep
160
+ `ok: true`; restarting dario cannot help either, and a watchdog that keys on
161
+ them just thrashes. Only auth rejection, 5xx, network failure and timeout set
162
+ `ok: false`.
163
+ - **`queue.stalledForMs` is the slot-exhaustion signal**, not `active`/`queued`.
164
+ A busy proxy legitimately sits at its concurrency cap with a backlog; the
165
+ failure mode in dario#905 was slots that stopped turning over entirely. Any
166
+ release resets the stall clock, so sustained load never trips it.
167
+
168
+ ### Who sees what
169
+
170
+ `/health` is auth-free by design — a docker healthcheck has to work before any
171
+ key is configured. The response body is therefore split two ways, while the HTTP
172
+ status (200/503) is identical for everyone, so uptime checks are unaffected:
173
+
174
+ | Caller | Gets |
175
+ |---|---|
176
+ | Presented a configured `DARIO_API_KEY` | full detail |
177
+ | Bare loopback (docker healthcheck, `dario doctor`) | full detail |
178
+ | Arrived through a Cloudflare tunnel (`cf-ray`) | `{"status": "ok"}` only |
179
+ | Anything else (LAN, another container, WAN) | `{"status": "ok"}` only |
180
+
181
+ > **Changed in 5.5.1.** "Presented a configured key" previously read as "passed
182
+ > the API-key check" — which every caller passes when **no** `DARIO_API_KEY` is
183
+ > set. On an unkeyed proxy published through a tunnel, that disclosed the OAuth
184
+ > countdown, request volume and refresh-failure count to anyone who asked. If
185
+ > you monitor `/health` through a tunnel with no key configured, you now get the
186
+ > liveness verdict only; set `DARIO_API_KEY` and send it, or query from
187
+ > loopback, to keep the detail.
188
+
189
+ A watchdog wants the probe; a container healthcheck usually does not:
190
+
191
+ ```bash
192
+ # liveness — restart only if the process itself is gone
193
+ curl -sf http://localhost:3456/livez
194
+
195
+ # real serving check, e.g. every 5 minutes
196
+ curl -sf 'http://localhost:3456/health?probe=1' >/dev/null || alert
197
+ ```
198
+
199
+ Knobs: `DARIO_PROBE_MODEL` (default `claude-haiku-4-5`),
200
+ `DARIO_PROBE_TTL_MS` (default `60000`), `DARIO_PROBE_TIMEOUT_MS` (default `15000`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.4.31",
3
+ "version": "5.5.1",
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": {