@askalf/dario 5.4.31 → 5.5.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.
@@ -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,7 +93,45 @@ 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 treats "authenticated" as sufficient, and
114
+ * `authenticateRequest` returns TRUE when no DARIO_API_KEY is configured at
115
+ * all. That is a reasonable convenience for the common loopback setup, and
116
+ * harmless for a read-only field. It is not harmless here: an unkeyed dario
117
+ * published through a Cloudflare tunnel would otherwise expose `?probe=1` as a
118
+ * button any anonymous caller could press to bill the operator, once per TTL,
119
+ * forever.
120
+ *
121
+ * So the probe additionally refuses anything that arrived through the tunnel,
122
+ * whatever `authenticated` says. This only ever DENIES — it cannot widen
123
+ * access — and it makes the spend path independent of whether an API key
124
+ * happens to be configured.
125
+ *
126
+ * Accepted trade-off: an operator who authenticates THROUGH the tunnel is also
127
+ * refused, and has to probe from beside the proxy instead. For a flag whose
128
+ * failure direction is "silently spends money", off is the right default.
129
+ */
130
+ export declare function shouldRunServingProbe(opts: {
131
+ requested: boolean;
132
+ discloseInternals: boolean;
133
+ viaCfRay: boolean;
134
+ }): boolean;
69
135
  /**
70
136
  * Decide whether a /health caller may see the OAuth internals (#642).
71
137
  *
@@ -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,12 +100,63 @@ 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 treats "authenticated" as sufficient, and
137
+ * `authenticateRequest` returns TRUE when no DARIO_API_KEY is configured at
138
+ * all. That is a reasonable convenience for the common loopback setup, and
139
+ * harmless for a read-only field. It is not harmless here: an unkeyed dario
140
+ * published through a Cloudflare tunnel would otherwise expose `?probe=1` as a
141
+ * button any anonymous caller could press to bill the operator, once per TTL,
142
+ * forever.
143
+ *
144
+ * So the probe additionally refuses anything that arrived through the tunnel,
145
+ * whatever `authenticated` says. This only ever DENIES — it cannot widen
146
+ * access — and it makes the spend path independent of whether an API key
147
+ * happens to be configured.
148
+ *
149
+ * Accepted trade-off: an operator who authenticates THROUGH the tunnel is also
150
+ * refused, and has to probe from beside the proxy instead. For a flag whose
151
+ * failure direction is "silently spends money", off is the right default.
152
+ */
153
+ export function shouldRunServingProbe(opts) {
154
+ if (!opts.requested)
155
+ return false;
156
+ if (opts.viaCfRay)
157
+ return false;
158
+ return opts.discloseInternals;
159
+ }
91
160
  /**
92
161
  * Decide whether a /health caller may see the OAuth internals (#642).
93
162
  *
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,36 @@ 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),
1702
1704
  loopback: isLoopbackAddr(req.socket?.remoteAddress),
1703
- viaCfRay: req.headers['cf-ray'] !== undefined,
1705
+ viaCfRay,
1704
1706
  });
1707
+ // Opt-in serving probe (#905). A real upstream round-trip costs a real
1708
+ // (tiny) billed request, so it runs only when explicitly asked for and
1709
+ // only for callers that clear shouldRunServingProbe — which is stricter
1710
+ // than the disclosure gate on purpose (see its docstring: an unkeyed
1711
+ // dario authenticates everyone, which must not turn `?probe=1` into a
1712
+ // spend button for the public internet). Cached and single-flighted
1713
+ // inside getServingProbe, so a monitor polling every second still costs
1714
+ // at most one probe per TTL.
1715
+ const wantsProbe = shouldRunServingProbe({
1716
+ requested: probeRequested(req.url),
1717
+ discloseInternals: includeInternal,
1718
+ viaCfRay,
1719
+ });
1720
+ const probe = wantsProbe
1721
+ ? await getServingProbe({
1722
+ fetchImpl: upstreamFetch,
1723
+ getToken: catalogDeps.getToken,
1724
+ upstreamApiKey: upstreamApiKey || undefined,
1725
+ })
1726
+ : undefined;
1705
1727
  const { httpStatus, body } = buildHealthResponse({
1706
1728
  ...s,
1707
1729
  version: darioVersion(),
1730
+ ...(probe ? { probe } : {}),
1708
1731
  // pool.size === 0 is single-account mode (session-id registry drives
1709
1732
  // the SESSION_ID slot); a loaded pool routes via sticky bindings.
1710
1733
  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,58 @@ 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** — authenticated, or loopback that did
154
+ not arrive through a Cloudflare tunnel (the same gate that governs the OAuth
155
+ internals). A world-readable `/health` is not a button for spending tokens.
156
+ - **Results are cached and single-flighted** (`DARIO_PROBE_TTL_MS`, default
157
+ 60000), so polling every second still costs at most one probe per minute.
158
+ - **A rate-limited or overloaded upstream is not an outage.** 429 and 529 keep
159
+ `ok: true`; restarting dario cannot help either, and a watchdog that keys on
160
+ them just thrashes. Only auth rejection, 5xx, network failure and timeout set
161
+ `ok: false`.
162
+ - **`queue.stalledForMs` is the slot-exhaustion signal**, not `active`/`queued`.
163
+ A busy proxy legitimately sits at its concurrency cap with a backlog; the
164
+ failure mode in dario#905 was slots that stopped turning over entirely. Any
165
+ release resets the stall clock, so sustained load never trips it.
166
+
167
+ A watchdog wants the probe; a container healthcheck usually does not:
168
+
169
+ ```bash
170
+ # liveness — restart only if the process itself is gone
171
+ curl -sf http://localhost:3456/livez
172
+
173
+ # real serving check, e.g. every 5 minutes
174
+ curl -sf 'http://localhost:3456/health?probe=1' >/dev/null || alert
175
+ ```
176
+
177
+ Knobs: `DARIO_PROBE_MODEL` (default `claude-haiku-4-5`),
178
+ `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.0",
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": {