@askalf/dario 6.0.12 → 6.0.13

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.
@@ -1,120 +1,35 @@
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
1
  export declare const DEFAULT_PROBE_MODEL = "claude-haiku-4-5";
69
2
  export declare const DEFAULT_PROBE_TTL_MS = 60000;
70
3
  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';
4
+ export type ProbeReason = 'served' | 'billing-required' | 'rate-limited' | 'upstream-overloaded' | 'no-token' | 'auth-rejected' | 'upstream-error' | 'timeout' | 'network-error';
76
5
  export interface ProbeResult {
77
- /** False only for conditions where dario is the problem — see module header. */
78
6
  ok: boolean;
79
7
  reason: ProbeReason;
80
- /** Epoch ms the round-trip completed. */
81
8
  checkedAt: number;
82
- /** Wall time of the round-trip, including token acquisition. */
83
9
  latencyMs: number;
84
10
  model: string;
85
- /** Upstream HTTP status, when upstream answered at all. */
86
11
  status?: number;
87
- /** Short failure detail. Never carries a token or a raw body. */
88
12
  detail?: string;
89
13
  }
90
14
  export interface ProbeDeps {
91
15
  fetchImpl?: typeof fetch;
92
- /** OAuth bearer source. Ignored when upstreamApiKey is set. */
93
16
  getToken?: () => Promise<string>;
94
- /** Per-token API mode — forwarded as x-api-key, mirroring request-path auth. */
95
17
  upstreamApiKey?: string;
96
18
  now?: () => number;
97
19
  model?: string;
98
20
  timeoutMs?: number;
99
21
  ttlMs?: number;
100
22
  }
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
- */
23
+ export declare function classifyProbeResponse(status: number, body?: string): {
24
+ ok: boolean;
25
+ reason: ProbeReason;
26
+ detail?: string;
27
+ };
28
+ /** Backward-compatible status-only classifier; body-aware callers use classifyProbeResponse. */
105
29
  export declare function classifyProbeStatus(status: number): {
106
30
  ok: boolean;
107
31
  reason: ProbeReason;
108
32
  };
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
33
  export declare function getServingProbe(deps?: ProbeDeps): Promise<ProbeResult>;
118
- /** Age of the cached verdict, for the `ageMs` field callers see. */
119
34
  export declare function probeAgeMs(result: ProbeResult, now: number): number;
120
35
  export declare function _resetServingProbeForTest(): void;
@@ -1,73 +1,11 @@
1
1
  /**
2
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.
3
+ * Opt-in, trusted-callers-only, cached and single-flighted.
66
4
  */
5
+ import { classifyUpstreamRejection, rejectionRemediation } from './upstream-rejection.js';
67
6
  const ANTHROPIC_MESSAGES = 'https://api.anthropic.com/v1/messages';
68
7
  const ANTHROPIC_VERSION = '2023-06-01';
69
8
  const OAUTH_BETA = 'oauth-2025-04-20';
70
- /** Cheapest family, and the one doctor's own probe already uses. */
71
9
  export const DEFAULT_PROBE_MODEL = 'claude-haiku-4-5';
72
10
  export const DEFAULT_PROBE_TTL_MS = 60_000;
73
11
  export const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
@@ -77,23 +15,31 @@ function envInt(name, dflt) {
77
15
  const v = Number(process.env[name]);
78
16
  return Number.isFinite(v) && v > 0 ? v : dflt;
79
17
  }
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) {
18
+ export function classifyProbeResponse(status, body = '') {
85
19
  if (status >= 200 && status < 300)
86
20
  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' };
21
+ // 429/529 are TRANSIENT: the seat is servable, the window is just closed.
22
+ // They stay ok:true (reason still reported) so a watchdog does not restart on
23
+ // an ordinary overage window — see test/health-verdict.mjs. Only states that
24
+ // do NOT self-clear (billing, credential, upstream-error) are ok:false.
90
25
  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' };
26
+ return { ok: true, reason: 'upstream-overloaded', detail: 'Upstream is overloaded; retry later.' };
27
+ const rejection = classifyUpstreamRejection(status, body);
28
+ if (rejection.class === 'billing') {
29
+ return { ok: false, reason: 'billing-required', detail: rejectionRemediation(rejection) };
30
+ }
31
+ if (rejection.class === 'rate_limit') {
32
+ return { ok: true, reason: 'rate-limited', detail: rejectionRemediation(rejection) };
33
+ }
34
+ if (rejection.class === 'credential') {
35
+ return { ok: false, reason: 'auth-rejected', detail: rejectionRemediation(rejection) };
36
+ }
37
+ return { ok: false, reason: 'upstream-error', detail: rejectionRemediation(rejection) };
38
+ }
39
+ /** Backward-compatible status-only classifier; body-aware callers use classifyProbeResponse. */
40
+ export function classifyProbeStatus(status) {
41
+ const { ok, reason } = classifyProbeResponse(status);
42
+ return { ok, reason };
97
43
  }
98
44
  async function runProbe(deps) {
99
45
  const f = deps.fetchImpl ?? fetch;
@@ -101,17 +47,10 @@ async function runProbe(deps) {
101
47
  const model = deps.model ?? process.env.DARIO_PROBE_MODEL ?? DEFAULT_PROBE_MODEL;
102
48
  const timeoutMs = deps.timeoutMs ?? envInt('DARIO_PROBE_TIMEOUT_MS', DEFAULT_PROBE_TIMEOUT_MS);
103
49
  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
50
  const ctl = new AbortController();
109
51
  const timer = setTimeout(() => ctl.abort(), timeoutMs);
110
52
  const finish = (r) => ({
111
- ...r,
112
- model,
113
- checkedAt: now(),
114
- latencyMs: now() - startedAt,
53
+ ...r, model, checkedAt: now(), latencyMs: now() - startedAt,
115
54
  });
116
55
  try {
117
56
  const headers = {
@@ -128,17 +67,15 @@ async function runProbe(deps) {
128
67
  try {
129
68
  token = await Promise.race([
130
69
  deps.getToken(),
131
- new Promise((_, rej) => {
132
- ctl.signal.addEventListener('abort', () => rej(new Error('token acquisition timed out')), { once: true });
70
+ new Promise((_, reject) => {
71
+ ctl.signal.addEventListener('abort', () => reject(new Error('token acquisition timed out')), { once: true });
133
72
  }),
134
73
  ]);
135
74
  }
136
75
  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
76
  return finish({ ok: false, reason: 'no-token', detail: errText(err) });
140
77
  }
141
- headers['authorization'] = `Bearer ${token}`;
78
+ headers.authorization = `Bearer ${token}`;
142
79
  headers['anthropic-beta'] = OAUTH_BETA;
143
80
  }
144
81
  const res = await f(ANTHROPIC_MESSAGES, {
@@ -147,12 +84,8 @@ async function runProbe(deps) {
147
84
  body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }),
148
85
  signal: ctl.signal,
149
86
  });
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 });
87
+ const body = await res.text().catch(() => '');
88
+ return finish({ ...classifyProbeResponse(res.status, body), status: res.status });
156
89
  }
157
90
  catch (err) {
158
91
  const aborted = ctl.signal.aborted || err?.name === 'AbortError';
@@ -169,14 +102,6 @@ async function runProbe(deps) {
169
102
  function errText(err) {
170
103
  return err instanceof Error ? err.message : String(err);
171
104
  }
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
105
  export async function getServingProbe(deps = {}) {
181
106
  const now = (deps.now ?? Date.now)();
182
107
  const ttl = deps.ttlMs ?? envInt('DARIO_PROBE_TTL_MS', DEFAULT_PROBE_TTL_MS);
@@ -185,16 +110,13 @@ export async function getServingProbe(deps = {}) {
185
110
  if (inflight !== null)
186
111
  return inflight;
187
112
  inflight = runProbe(deps)
188
- .then((r) => {
189
- cache = r;
190
- return r;
113
+ .then((result) => {
114
+ cache = result;
115
+ return result;
191
116
  })
192
- .finally(() => {
193
- inflight = null;
194
- });
117
+ .finally(() => { inflight = null; });
195
118
  return inflight;
196
119
  }
197
- /** Age of the cached verdict, for the `ageMs` field callers see. */
198
120
  export function probeAgeMs(result, now) {
199
121
  return Math.max(0, now - result.checkedAt);
200
122
  }
@@ -1,9 +1,13 @@
1
- export type UpstreamRejectionClass = 'billing' | 'rate_limit' | 'other';
1
+ export type UpstreamRejectionClass = 'billing' | 'rate_limit' | 'credential' | 'other';
2
2
  export interface UpstreamRejection {
3
3
  class: UpstreamRejectionClass;
4
- marker: 'billing_required' | 'rate_limited' | 'upstream_rejected';
4
+ marker: 'billing_required' | 'rate_limited' | 'credential_rejected' | 'upstream_rejected';
5
5
  }
6
6
  /** Classify subscription entitlement failures separately from temporary quota exhaustion. */
7
7
  export declare function classifyUpstreamRejection(status: number, body: string): UpstreamRejection;
8
+ /** Operator action paired with the failure class. Never suggest credential churn for billing. */
9
+ export declare function rejectionRemediation(rejection: UpstreamRejection): string;
10
+ /** Stable reason string for health, doctor, and workflow consumers. */
11
+ export declare function rejectionReason(rejection: UpstreamRejection): string;
8
12
  /** Bounded diagnostic text; callers must apply their standard secret redactor first. */
9
13
  export declare function diagnosticSnippet(body: string, maxLength?: number): string;
@@ -10,8 +10,34 @@ export function classifyUpstreamRejection(status, body) {
10
10
  return { class: 'billing', marker: 'billing_required' };
11
11
  if (status === 429)
12
12
  return { class: 'rate_limit', marker: 'rate_limited' };
13
+ if (status === 401 || normalized.includes('authentication_error') || normalized.includes('invalid_grant')) {
14
+ return { class: 'credential', marker: 'credential_rejected' };
15
+ }
13
16
  return { class: 'other', marker: 'upstream_rejected' };
14
17
  }
18
+ /** Operator action paired with the failure class. Never suggest credential churn for billing. */
19
+ export function rejectionRemediation(rejection) {
20
+ switch (rejection.class) {
21
+ case 'billing':
22
+ return 'The subscription or payment method needs operator attention. Restarting, re-transplanting credentials, logging in again, or removing the pool account will not help.';
23
+ case 'rate_limit':
24
+ return 'The quota window self-clears; wait for the upstream reset window, then retry.';
25
+ case 'credential':
26
+ return 'The credential was rejected; follow the OAuth re-authentication runbook.';
27
+ default:
28
+ return 'Upstream rejected the request for an unclassified reason; inspect the bounded diagnostic before changing credentials.';
29
+ }
30
+ }
31
+ /** Stable reason string for health, doctor, and workflow consumers. */
32
+ export function rejectionReason(rejection) {
33
+ if (rejection.class === 'billing')
34
+ return 'billing-required';
35
+ if (rejection.class === 'rate_limit')
36
+ return 'rate-limited';
37
+ if (rejection.class === 'credential')
38
+ return 'auth-rejected';
39
+ return 'upstream-rejected';
40
+ }
15
41
  /** Bounded diagnostic text; callers must apply their standard secret redactor first. */
16
42
  export function diagnosticSnippet(body, maxLength = 500) {
17
43
  return body.replace(/\s+/g, ' ').trim().slice(0, maxLength);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.12",
3
+ "version": "6.0.13",
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": {