@askalf/dario 6.0.12 → 6.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codex-backend.d.ts +7 -1
- package/dist/codex-backend.js +16 -1
- package/dist/doctor-core.d.ts +239 -0
- package/dist/doctor-core.js +1234 -0
- package/dist/doctor-serving.d.ts +7 -0
- package/dist/doctor-serving.js +17 -0
- package/dist/doctor.d.ts +17 -234
- package/dist/doctor.js +32 -1226
- package/dist/provider-cooldown.d.ts +78 -0
- package/dist/provider-cooldown.js +120 -0
- package/dist/proxy.js +119 -10
- package/dist/serving-probe.d.ts +7 -92
- package/dist/serving-probe.js +34 -112
- package/dist/upstream-rejection.d.ts +6 -2
- package/dist/upstream-rejection.js +26 -0
- package/package.json +1 -1
package/dist/serving-probe.js
CHANGED
|
@@ -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
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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((_,
|
|
132
|
-
ctl.signal.addEventListener('abort', () =>
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
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((
|
|
189
|
-
cache =
|
|
190
|
-
return
|
|
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.
|
|
3
|
+
"version": "6.0.14",
|
|
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": {
|