@askalf/dario 4.0.1 → 4.1.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.
package/dist/cli.js CHANGED
@@ -189,6 +189,53 @@ async function refresh() {
189
189
  process.exit(1);
190
190
  }
191
191
  }
192
+ async function resume() {
193
+ // v4.1, dario#288 — clear the overage-guard halt state on a running
194
+ // dario proxy via POST /admin/resume. The proxy returns 200 with
195
+ // {ok, wasHalted, resumedAt}; we surface that to the operator.
196
+ //
197
+ // Port resolution mirrors `dario doctor` — --port flag > DARIO_PORT
198
+ // env > config file > 3456 default. Auth: DARIO_API_KEY when set
199
+ // (matches the same auth chain dario applies to every endpoint).
200
+ const { loadConfig } = await import('./config-file.js');
201
+ const fileResult = loadConfig();
202
+ const portArg = args.find(a => a.startsWith('--port='));
203
+ const portFromCli = portArg ? parseInt(portArg.split('=')[1], 10) : undefined;
204
+ const portFromEnv = process.env['DARIO_PORT'] ? parseInt(process.env['DARIO_PORT'], 10) : undefined;
205
+ const port = portFromCli ?? portFromEnv ?? fileResult.config.port ?? 3456;
206
+ const url = `http://127.0.0.1:${port}/admin/resume`;
207
+ const headers = { 'Content-Type': 'application/json' };
208
+ const apiKey = process.env['DARIO_API_KEY'];
209
+ if (apiKey) {
210
+ headers['Authorization'] = `Bearer ${apiKey}`;
211
+ }
212
+ let resp;
213
+ try {
214
+ resp = await fetch(url, { method: 'POST', headers, body: '{}' });
215
+ }
216
+ catch (err) {
217
+ const msg = err.message;
218
+ // The proxy-not-running case is the common failure path; surface a
219
+ // friendly hint instead of a raw Node fetch error.
220
+ if (/ECONNREFUSED|fetch failed/i.test(msg)) {
221
+ console.error(`[dario] No proxy running on localhost:${port}. Start one with \`dario proxy\` (overage-guard state is per-process; there's nothing to resume on a stopped proxy).`);
222
+ process.exit(1);
223
+ }
224
+ console.error(`[dario] Resume request failed: ${msg}`);
225
+ process.exit(1);
226
+ }
227
+ if (!resp.ok) {
228
+ console.error(`[dario] Resume request returned HTTP ${resp.status}. Body: ${(await resp.text()).slice(0, 500)}`);
229
+ process.exit(1);
230
+ }
231
+ const result = await resp.json();
232
+ if (result.wasHalted) {
233
+ console.log(`[dario] Resumed at ${result.resumedAt}. Proxy returning to normal request handling.`);
234
+ }
235
+ else {
236
+ console.log(`[dario] Proxy was not halted — no-op. (Overage-guard state was already clear.)`);
237
+ }
238
+ }
192
239
  async function logout() {
193
240
  const path = join(homedir(), '.dario', 'credentials.json');
194
241
  try {
@@ -423,6 +470,46 @@ async function proxy() {
423
470
  // billable-filter. Empty values are dropped. Falls back to
424
471
  // DARIO_PASSTHROUGH_BETAS env var.
425
472
  const passthroughBetas = parsePassthroughBetasFlag(args, process.env['DARIO_PASSTHROUGH_BETAS']);
473
+ // --overage-guard / --no-overage-guard / DARIO_OVERAGE_GUARD=off|on (v4.1)
474
+ // When any upstream response carries `representative-claim: overage`,
475
+ // halt the proxy: every new request returns 503 with an Anthropic-shaped
476
+ // error body until cooldown expires or `dario resume` clears the state.
477
+ // Subscribers should never see an overage hit during normal operation,
478
+ // so this defaults ON — the cost of a false negative (silent per-token
479
+ // billing) far exceeds the cost of a false positive (one disrupted
480
+ // session that resumes with a single command). See dario#288.
481
+ //
482
+ // --no-overage-guard → fully disabled
483
+ // --overage-behavior=halt|warn → halt (default) or warn-only (no 503)
484
+ // --overage-cooldown=<MS> → auto-resume after this delay (default 30 min)
485
+ // --no-overage-notify → suppress OS-level desktop notification
486
+ // DARIO_OVERAGE_GUARD=off → env equivalent of --no-overage-guard
487
+ // DARIO_OVERAGE_BEHAVIOR=halt|warn → env equivalent of --overage-behavior
488
+ // DARIO_OVERAGE_COOLDOWN=<MS> → env equivalent of --overage-cooldown
489
+ // DARIO_OVERAGE_NOTIFY=off → env equivalent of --no-overage-notify
490
+ const overageGuardEnabledFromFlag = args.includes('--no-overage-guard') ? false
491
+ : args.includes('--overage-guard') ? true : undefined;
492
+ const overageGuardEnabledFromEnv = parseBooleanEnv(process.env['DARIO_OVERAGE_GUARD']);
493
+ const overageGuardEnabled = overageGuardEnabledFromFlag
494
+ ?? overageGuardEnabledFromEnv
495
+ ?? fileCfg.overageGuard?.enabled
496
+ ?? true;
497
+ const overageBehaviorFromFlag = args.find((a) => a.startsWith('--overage-behavior='))?.split('=').slice(1).join('=');
498
+ const overageBehaviorFromEnv = process.env['DARIO_OVERAGE_BEHAVIOR'];
499
+ const overageBehaviorRaw = overageBehaviorFromFlag ?? overageBehaviorFromEnv;
500
+ const overageGuardBehavior = overageBehaviorRaw === 'halt' || overageBehaviorRaw === 'warn'
501
+ ? overageBehaviorRaw
502
+ : (fileCfg.overageGuard?.behavior ?? 'halt');
503
+ const overageGuardCooldownMs = parsePositiveIntFlag('--overage-cooldown=')
504
+ ?? parsePositiveIntEnv(process.env['DARIO_OVERAGE_COOLDOWN'])
505
+ ?? fileCfg.overageGuard?.cooldownMs
506
+ ?? 30 * 60 * 1000;
507
+ const overageNotifyFromFlag = args.includes('--no-overage-notify') ? false : undefined;
508
+ const overageNotifyFromEnv = parseBooleanEnv(process.env['DARIO_OVERAGE_NOTIFY']);
509
+ const overageGuardNotifyOs = overageNotifyFromFlag
510
+ ?? overageNotifyFromEnv
511
+ ?? fileCfg.overageGuard?.notifyOs
512
+ ?? true;
426
513
  // Non-loopback bind without DARIO_API_KEY turns dario into an open
427
514
  // OAuth-subscription relay for anyone on the reachable network. Refuse
428
515
  // to start rather than rely on the operator to read the startup banner.
@@ -442,7 +529,7 @@ async function proxy() {
442
529
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
443
530
  process.exit(1);
444
531
  }
445
- await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, effort, maxTokens, logFile, passthroughBetas, systemPrompt });
532
+ await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, effort, maxTokens, logFile, passthroughBetas, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs });
446
533
  }
447
534
  /**
448
535
  * Parse `--system-prompt=<verbatim|partial|aggressive|filepath>` (or the
@@ -1802,6 +1889,7 @@ const commands = {
1802
1889
  status,
1803
1890
  proxy,
1804
1891
  refresh,
1892
+ resume,
1805
1893
  logout,
1806
1894
  accounts,
1807
1895
  backend,
@@ -86,6 +86,32 @@ export interface DarioConfig {
86
86
  systemPrompt?: string | null;
87
87
  preserveOrchestrationTags?: boolean;
88
88
  logFile?: string | null;
89
+ /**
90
+ * Overage-guard — halt the proxy on the first response carrying
91
+ * `representative-claim: overage`. Subscribers should never see a
92
+ * single overage hit during normal operation; one means something
93
+ * is wrong (wire-shape drift, classifier change, account misconfig)
94
+ * and continuing to forward requests bleeds against per-token
95
+ * billing. See dario#288.
96
+ *
97
+ * `behavior: 'halt'` — return 503 with an Anthropic-shaped error
98
+ * body until cooldown expires or `dario resume`
99
+ * runs. Default.
100
+ * `behavior: 'warn'` — emit the SSE event + OS notification but
101
+ * leave proxy behavior unchanged.
102
+ *
103
+ * `cooldownMs` — auto-resume delay after a halt. 30 min default.
104
+ *
105
+ * `notifyOs` — best-effort native desktop notification on halt
106
+ * (osascript/notify-send/BurntToast); terminal BEL is
107
+ * the unconditional floor.
108
+ */
109
+ overageGuard?: {
110
+ enabled?: boolean;
111
+ behavior?: 'halt' | 'warn';
112
+ cooldownMs?: number;
113
+ notifyOs?: boolean;
114
+ };
89
115
  }
90
116
  /**
91
117
  * Defaults match the v3.x CLI flag defaults exactly. Any value not
@@ -71,6 +71,12 @@ export function defaultConfig() {
71
71
  systemPrompt: null,
72
72
  preserveOrchestrationTags: false,
73
73
  logFile: null,
74
+ overageGuard: {
75
+ enabled: true,
76
+ behavior: 'halt',
77
+ cooldownMs: 30 * 60 * 1000,
78
+ notifyOs: true,
79
+ },
74
80
  };
75
81
  }
76
82
  /**
@@ -320,6 +326,23 @@ function sanitize(parsed) {
320
326
  const logFile = pickStringOrNull('logFile');
321
327
  if (logFile !== undefined)
322
328
  out.logFile = logFile;
329
+ if (isPlainObject(parsed.overageGuard)) {
330
+ out.overageGuard = {};
331
+ if (typeof parsed.overageGuard.enabled === 'boolean') {
332
+ out.overageGuard.enabled = parsed.overageGuard.enabled;
333
+ }
334
+ if (parsed.overageGuard.behavior === 'halt' || parsed.overageGuard.behavior === 'warn') {
335
+ out.overageGuard.behavior = parsed.overageGuard.behavior;
336
+ }
337
+ if (typeof parsed.overageGuard.cooldownMs === 'number'
338
+ && Number.isFinite(parsed.overageGuard.cooldownMs)
339
+ && parsed.overageGuard.cooldownMs >= 0) {
340
+ out.overageGuard.cooldownMs = parsed.overageGuard.cooldownMs;
341
+ }
342
+ if (typeof parsed.overageGuard.notifyOs === 'boolean') {
343
+ out.overageGuard.notifyOs = parsed.overageGuard.notifyOs;
344
+ }
345
+ }
323
346
  // Silence unused-warning helper.
324
347
  void pickNumberOrNull;
325
348
  return out;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Best-effort cross-platform desktop notification dispatcher.
3
+ *
4
+ * Pure Node, no new dependencies. Resolves the platform's native toast
5
+ * mechanism at load time, falls back to the terminal BEL character on any
6
+ * platform that doesn't have one (or where the native path is missing).
7
+ *
8
+ * Backends:
9
+ * - macOS: `osascript -e 'display notification "msg" with title "dario"'`
10
+ * - Linux: `notify-send "dario" "msg"` (gnome / kde / dunst / mako)
11
+ * - Windows: `powershell -Command New-BurntToastNotification ...` if the
12
+ * `BurntToast` module is installed; falls back to `msg.exe`,
13
+ * else BEL only.
14
+ *
15
+ * BEL char (`\x07`) is the unconditional floor — works on every terminal
16
+ * that respects ANSI control characters, which is nearly all of them.
17
+ *
18
+ * Silent on failure: a missing `osascript`/`notify-send`/PowerShell path
19
+ * is the common case for non-interactive sessions, headless CI runs, and
20
+ * SSH-into-a-server flows. The TUI banner is the authoritative surface;
21
+ * OS-notify is the loud, attention-grabbing supplement.
22
+ *
23
+ * See dario#288 — overage-guard.
24
+ */
25
+ /**
26
+ * Fire a native notification. Returns immediately — the underlying spawn
27
+ * is fire-and-forget. Errors (missing binary, permission denied, no
28
+ * graphical session) are swallowed; the caller already has the in-app
29
+ * surface and shouldn't depend on this firing.
30
+ *
31
+ * `title` and `message` are passed verbatim except for shell-meta escaping
32
+ * — single quotes and backticks are stripped so the AppleScript / shell
33
+ * payload can't be hijacked by a malicious upstream response.
34
+ */
35
+ export declare function notify(title: string, message: string): void;
36
+ /**
37
+ * Test-mode hook — returns a notifier that pushes into a captured array
38
+ * instead of firing real OS notifications. Used by test/notify-cross-
39
+ * platform.mjs to verify the dispatch path without invoking osascript.
40
+ */
41
+ export declare function captureNotifier(): {
42
+ notify: (title: string, message: string) => void;
43
+ captured: Array<{
44
+ title: string;
45
+ message: string;
46
+ ts: number;
47
+ }>;
48
+ };
package/dist/notify.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Best-effort cross-platform desktop notification dispatcher.
3
+ *
4
+ * Pure Node, no new dependencies. Resolves the platform's native toast
5
+ * mechanism at load time, falls back to the terminal BEL character on any
6
+ * platform that doesn't have one (or where the native path is missing).
7
+ *
8
+ * Backends:
9
+ * - macOS: `osascript -e 'display notification "msg" with title "dario"'`
10
+ * - Linux: `notify-send "dario" "msg"` (gnome / kde / dunst / mako)
11
+ * - Windows: `powershell -Command New-BurntToastNotification ...` if the
12
+ * `BurntToast` module is installed; falls back to `msg.exe`,
13
+ * else BEL only.
14
+ *
15
+ * BEL char (`\x07`) is the unconditional floor — works on every terminal
16
+ * that respects ANSI control characters, which is nearly all of them.
17
+ *
18
+ * Silent on failure: a missing `osascript`/`notify-send`/PowerShell path
19
+ * is the common case for non-interactive sessions, headless CI runs, and
20
+ * SSH-into-a-server flows. The TUI banner is the authoritative surface;
21
+ * OS-notify is the loud, attention-grabbing supplement.
22
+ *
23
+ * See dario#288 — overage-guard.
24
+ */
25
+ import { spawn } from 'node:child_process';
26
+ import { platform } from 'node:os';
27
+ /**
28
+ * Fire a native notification. Returns immediately — the underlying spawn
29
+ * is fire-and-forget. Errors (missing binary, permission denied, no
30
+ * graphical session) are swallowed; the caller already has the in-app
31
+ * surface and shouldn't depend on this firing.
32
+ *
33
+ * `title` and `message` are passed verbatim except for shell-meta escaping
34
+ * — single quotes and backticks are stripped so the AppleScript / shell
35
+ * payload can't be hijacked by a malicious upstream response.
36
+ */
37
+ export function notify(title, message) {
38
+ // Always BEL first — works in every TTY, doesn't depend on a graphical
39
+ // session. The OS notification on top is best-effort.
40
+ try {
41
+ process.stderr.write('\x07');
42
+ }
43
+ catch {
44
+ // stderr write can fail under exotic conditions (closed handle,
45
+ // detached process); silent — we have nothing else to fall back to.
46
+ }
47
+ const safeTitle = sanitize(title);
48
+ const safeMessage = sanitize(message);
49
+ const plat = platform();
50
+ try {
51
+ if (plat === 'darwin') {
52
+ // AppleScript single-quote inside the script body would need
53
+ // escaping; sanitize() strips them so the literal substitution
54
+ // below stays safe. Spawned via array argv to avoid a shell
55
+ // entirely.
56
+ spawn('osascript', [
57
+ '-e',
58
+ `display notification "${safeMessage}" with title "${safeTitle}"`,
59
+ ], { detached: true, stdio: 'ignore' }).unref();
60
+ }
61
+ else if (plat === 'linux') {
62
+ spawn('notify-send', [safeTitle, safeMessage], {
63
+ detached: true,
64
+ stdio: 'ignore',
65
+ }).unref();
66
+ }
67
+ else if (plat === 'win32') {
68
+ // BurntToast is the cleanest path — single-line PowerShell, real
69
+ // Windows toast notification. If BurntToast isn't installed the
70
+ // command fails silently (stdio: ignore swallows the error
71
+ // output), which is the desired behavior.
72
+ //
73
+ // We don't probe-then-spawn; the cost of one failed BurntToast
74
+ // attempt is the same as one probe attempt, and probing makes the
75
+ // hot path slower for the success case.
76
+ const ps = `try { Import-Module BurntToast -ErrorAction Stop; New-BurntToastNotification -Text '${safeTitle}', '${safeMessage}' } catch { exit 1 }`;
77
+ spawn('powershell.exe', [
78
+ '-NoProfile',
79
+ '-NonInteractive',
80
+ '-Command',
81
+ ps,
82
+ ], { detached: true, stdio: 'ignore' }).unref();
83
+ }
84
+ // freebsd / openbsd / aix / sunos / android — BEL only. There's no
85
+ // single "right" native notification on these platforms.
86
+ }
87
+ catch {
88
+ // spawn() itself can throw on EMFILE or similar; silent.
89
+ }
90
+ }
91
+ /**
92
+ * Strip characters that would break the embedded shell/AppleScript
93
+ * payload or allow command injection. Conservative: only allow printable
94
+ * ASCII + common Unicode word chars + a small whitelist of punctuation.
95
+ *
96
+ * This is NOT a general-purpose sanitizer; it exists to defang text we
97
+ * already control (our own messages) from accidentally containing
98
+ * AppleScript-breaking characters like a stray double quote.
99
+ */
100
+ function sanitize(s) {
101
+ return s
102
+ .replace(/[\r\n]/g, ' ') // collapse newlines
103
+ .replace(/[`'"$]/g, '') // strip shell metas + quotes
104
+ .replace(/\\/g, '/') // strip backslashes
105
+ .slice(0, 200); // cap length; notifications truncate anyway
106
+ }
107
+ /**
108
+ * Test-mode hook — returns a notifier that pushes into a captured array
109
+ * instead of firing real OS notifications. Used by test/notify-cross-
110
+ * platform.mjs to verify the dispatch path without invoking osascript.
111
+ */
112
+ export function captureNotifier() {
113
+ const captured = [];
114
+ return {
115
+ notify: (title, message) => {
116
+ captured.push({ title, message, ts: Date.now() });
117
+ },
118
+ captured,
119
+ };
120
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Overage-guard — halt the proxy on the first `representative-claim: overage`
3
+ * response to prevent silent API-rate bleed.
4
+ *
5
+ * Subscribers should never see a single overage hit during normal
6
+ * operation. One means something is wrong (wire-shape drift, classifier
7
+ * change, account misconfig, billing-flip after a CC release) and
8
+ * continuing to forward requests bleeds against per-token billing.
9
+ *
10
+ * The guard subscribes to the Analytics record stream — every completed
11
+ * request emits a record carrying its `claim` (raw representative-claim
12
+ * value). When `claim === 'overage'` lands, the guard transitions to a
13
+ * halted state and emits a `'halt'` event. The HTTP request path checks
14
+ * `isHalted()` on every incoming request and returns 503 with an
15
+ * Anthropic-shaped error body when halted.
16
+ *
17
+ * Resume paths:
18
+ * - explicit: `dario resume` CLI → POST /admin/resume → `clear('manual')`
19
+ * - automatic: cooldown expires (default 30 min) → `clear('cooldown')`
20
+ * - TUI: `r` key on Status tab → POST /admin/resume (same as CLI)
21
+ *
22
+ * Behavior:
23
+ * - `halt` (default) — record halted state + return 503 on subsequent requests
24
+ * - `warn` — emit events + notify only; proxy keeps forwarding (visibility-only mode)
25
+ *
26
+ * See dario#288.
27
+ */
28
+ import { EventEmitter } from 'node:events';
29
+ import type { Analytics, RequestRecord } from './analytics.js';
30
+ export interface HaltState {
31
+ since: number;
32
+ cooldownUntil: number;
33
+ reason: 'overage_detected';
34
+ request: {
35
+ timestamp: number;
36
+ model: string;
37
+ account: string;
38
+ claim: string;
39
+ };
40
+ }
41
+ export interface OverageGuardOptions {
42
+ enabled: boolean;
43
+ behavior: 'halt' | 'warn';
44
+ cooldownMs: number;
45
+ notifyOs: boolean;
46
+ /**
47
+ * Best-effort native desktop notification dispatcher. Pass the function
48
+ * from `./notify.ts` here. Optional — silent failure if absent. The
49
+ * guard always emits the `'halt'` event for in-process subscribers
50
+ * (the SSE stream, the TUI) regardless of whether OS-notify fired.
51
+ */
52
+ notifier?: (title: string, message: string) => void;
53
+ }
54
+ export declare class OverageGuard extends EventEmitter {
55
+ private opts;
56
+ private halted;
57
+ private cooldownTimer;
58
+ private analyticsListener;
59
+ constructor(opts: OverageGuardOptions);
60
+ /**
61
+ * Subscribe to an Analytics instance. Every record emitted with
62
+ * `claim === 'overage'` triggers halt (when behavior === 'halt') or a
63
+ * warn-only event (when behavior === 'warn').
64
+ *
65
+ * Idempotent — calling attach() a second time replaces the listener
66
+ * rather than stacking; useful for tests.
67
+ */
68
+ attach(analytics: Analytics): void;
69
+ /**
70
+ * Synthesize a halt event from a record. Public for the test harness;
71
+ * production code reaches this via attach() + the live Analytics stream.
72
+ */
73
+ onOverageDetected(r: RequestRecord): void;
74
+ /**
75
+ * Resume the proxy. Emits a 'resume' event with the reason.
76
+ *
77
+ * No-op when not currently halted. Safe to call from any path
78
+ * (CLI, /admin/resume HTTP endpoint, TUI `r` key, cooldown timer).
79
+ */
80
+ clear(reason: 'manual' | 'cooldown'): void;
81
+ /** Current halt state, or `null` if not halted. */
82
+ state(): HaltState | null;
83
+ /** Quick boolean for the request hot-path. */
84
+ isHalted(): boolean;
85
+ /** Detach from Analytics. Used by tests and by graceful shutdown. */
86
+ destroy(): void;
87
+ /** Expose options for the /status endpoint + TUI Status tab. */
88
+ config(): Readonly<OverageGuardOptions>;
89
+ }
90
+ /**
91
+ * The Anthropic-shaped error body returned by halted-503 responses. The
92
+ * shape matches what `api.anthropic.com` emits for any 4xx so CC /
93
+ * Cursor / Aider / Cline surface the message verbatim to the user — no
94
+ * client-specific handling needed.
95
+ */
96
+ export declare function buildHaltErrorBody(state: HaltState): {
97
+ type: 'error';
98
+ error: {
99
+ type: string;
100
+ message: string;
101
+ };
102
+ };
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Overage-guard — halt the proxy on the first `representative-claim: overage`
3
+ * response to prevent silent API-rate bleed.
4
+ *
5
+ * Subscribers should never see a single overage hit during normal
6
+ * operation. One means something is wrong (wire-shape drift, classifier
7
+ * change, account misconfig, billing-flip after a CC release) and
8
+ * continuing to forward requests bleeds against per-token billing.
9
+ *
10
+ * The guard subscribes to the Analytics record stream — every completed
11
+ * request emits a record carrying its `claim` (raw representative-claim
12
+ * value). When `claim === 'overage'` lands, the guard transitions to a
13
+ * halted state and emits a `'halt'` event. The HTTP request path checks
14
+ * `isHalted()` on every incoming request and returns 503 with an
15
+ * Anthropic-shaped error body when halted.
16
+ *
17
+ * Resume paths:
18
+ * - explicit: `dario resume` CLI → POST /admin/resume → `clear('manual')`
19
+ * - automatic: cooldown expires (default 30 min) → `clear('cooldown')`
20
+ * - TUI: `r` key on Status tab → POST /admin/resume (same as CLI)
21
+ *
22
+ * Behavior:
23
+ * - `halt` (default) — record halted state + return 503 on subsequent requests
24
+ * - `warn` — emit events + notify only; proxy keeps forwarding (visibility-only mode)
25
+ *
26
+ * See dario#288.
27
+ */
28
+ import { EventEmitter } from 'node:events';
29
+ export class OverageGuard extends EventEmitter {
30
+ opts;
31
+ halted = null;
32
+ cooldownTimer = null;
33
+ analyticsListener = null;
34
+ constructor(opts) {
35
+ super();
36
+ // /analytics/stream + TUI tabs each register a listener; the in-proc
37
+ // event listeners ceiling matches the Analytics class's choice.
38
+ this.setMaxListeners(100);
39
+ this.opts = opts;
40
+ }
41
+ /**
42
+ * Subscribe to an Analytics instance. Every record emitted with
43
+ * `claim === 'overage'` triggers halt (when behavior === 'halt') or a
44
+ * warn-only event (when behavior === 'warn').
45
+ *
46
+ * Idempotent — calling attach() a second time replaces the listener
47
+ * rather than stacking; useful for tests.
48
+ */
49
+ attach(analytics) {
50
+ if (this.analyticsListener) {
51
+ analytics.off('record', this.analyticsListener);
52
+ }
53
+ if (!this.opts.enabled) {
54
+ // Guard fully disabled — don't even register the listener. No
55
+ // detection, no halt, no events.
56
+ this.analyticsListener = null;
57
+ return;
58
+ }
59
+ this.analyticsListener = (r) => {
60
+ if (r.claim === 'overage') {
61
+ this.onOverageDetected(r);
62
+ }
63
+ };
64
+ analytics.on('record', this.analyticsListener);
65
+ }
66
+ /**
67
+ * Synthesize a halt event from a record. Public for the test harness;
68
+ * production code reaches this via attach() + the live Analytics stream.
69
+ */
70
+ onOverageDetected(r) {
71
+ if (this.halted) {
72
+ // Already halted — don't re-fire halt events. The original halt
73
+ // state stays in place until cleared. A second overage hit while
74
+ // halted is expected (the client may not have noticed the 503
75
+ // yet); silent.
76
+ return;
77
+ }
78
+ const state = {
79
+ since: Date.now(),
80
+ cooldownUntil: Date.now() + this.opts.cooldownMs,
81
+ reason: 'overage_detected',
82
+ request: {
83
+ timestamp: r.timestamp,
84
+ model: r.model,
85
+ account: r.account,
86
+ claim: r.claim,
87
+ },
88
+ };
89
+ if (this.opts.behavior === 'halt') {
90
+ this.halted = state;
91
+ // Schedule auto-resume. Timer reference is held so we can cancel
92
+ // it on a manual resume — otherwise a manual resume followed by
93
+ // continued use, then the original cooldown firing, would emit a
94
+ // spurious second 'resume' event.
95
+ this.cooldownTimer = setTimeout(() => {
96
+ if (this.halted && this.halted.since === state.since) {
97
+ this.clear('cooldown');
98
+ }
99
+ }, this.opts.cooldownMs);
100
+ this.cooldownTimer.unref();
101
+ }
102
+ // Always fire 'halt' (or 'warn') so SSE subscribers see the event
103
+ // even in warn-only mode — the TUI's job is to surface this to the
104
+ // user regardless of whether the proxy chose to block traffic.
105
+ const eventName = this.opts.behavior === 'halt' ? 'halt' : 'warn';
106
+ try {
107
+ this.emit(eventName, state);
108
+ }
109
+ catch (err) {
110
+ // A subscriber threw — log + swallow, don't crash on event side-effects.
111
+ console.error('[dario] overage-guard subscriber threw:', err.message);
112
+ }
113
+ if (this.opts.notifyOs && this.opts.notifier) {
114
+ try {
115
+ const title = this.opts.behavior === 'halt' ? 'dario halted' : 'dario warning';
116
+ const msg = `Request classified as 'overage' (per-token billing)${this.opts.behavior === 'halt' ? '. Proxy halted. Run `dario resume` to continue.' : ''}`;
117
+ this.opts.notifier(title, msg);
118
+ }
119
+ catch {
120
+ // Native notification failure is non-fatal. Already emitted to
121
+ // SSE / TUI; the user gets the in-app banner regardless.
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Resume the proxy. Emits a 'resume' event with the reason.
127
+ *
128
+ * No-op when not currently halted. Safe to call from any path
129
+ * (CLI, /admin/resume HTTP endpoint, TUI `r` key, cooldown timer).
130
+ */
131
+ clear(reason) {
132
+ if (!this.halted)
133
+ return;
134
+ const wasHaltedAt = this.halted.since;
135
+ this.halted = null;
136
+ if (this.cooldownTimer) {
137
+ clearTimeout(this.cooldownTimer);
138
+ this.cooldownTimer = null;
139
+ }
140
+ try {
141
+ this.emit('resume', { reason, previousSince: wasHaltedAt });
142
+ }
143
+ catch (err) {
144
+ console.error('[dario] overage-guard resume subscriber threw:', err.message);
145
+ }
146
+ }
147
+ /** Current halt state, or `null` if not halted. */
148
+ state() {
149
+ return this.halted;
150
+ }
151
+ /** Quick boolean for the request hot-path. */
152
+ isHalted() {
153
+ return this.halted !== null && this.opts.behavior === 'halt';
154
+ }
155
+ /** Detach from Analytics. Used by tests and by graceful shutdown. */
156
+ destroy() {
157
+ this.removeAllListeners();
158
+ if (this.cooldownTimer) {
159
+ clearTimeout(this.cooldownTimer);
160
+ this.cooldownTimer = null;
161
+ }
162
+ this.halted = null;
163
+ this.analyticsListener = null;
164
+ }
165
+ /** Expose options for the /status endpoint + TUI Status tab. */
166
+ config() {
167
+ return this.opts;
168
+ }
169
+ }
170
+ /**
171
+ * The Anthropic-shaped error body returned by halted-503 responses. The
172
+ * shape matches what `api.anthropic.com` emits for any 4xx so CC /
173
+ * Cursor / Aider / Cline surface the message verbatim to the user — no
174
+ * client-specific handling needed.
175
+ */
176
+ export function buildHaltErrorBody(state) {
177
+ const isoCooldown = new Date(state.cooldownUntil).toISOString();
178
+ return {
179
+ type: 'error',
180
+ error: {
181
+ type: 'dario_overage_guard',
182
+ message: `dario halted to prevent API-rate bleed. A request was classified ` +
183
+ `as 'overage' (per-token billing) instead of your subscription pool. ` +
184
+ `To resume: run \`dario resume\` in another terminal, or wait until ` +
185
+ `${isoCooldown} for the cooldown to auto-clear. ` +
186
+ `Details: github.com/askalf/dario/issues/288`,
187
+ },
188
+ };
189
+ }
package/dist/proxy.d.ts CHANGED
@@ -162,6 +162,20 @@ interface ProxyOptions {
162
162
  * Sourced from `--system-prompt=<value>` or DARIO_SYSTEM_PROMPT.
163
163
  */
164
164
  systemPrompt?: string;
165
+ /**
166
+ * Overage-guard — halt the proxy on the first response carrying
167
+ * `representative-claim: overage`. Subscribers should never see a
168
+ * single overage hit during normal operation; one means something
169
+ * is wrong (wire-shape drift, classifier change, account misconfig)
170
+ * and continuing to forward bleeds against per-token billing.
171
+ *
172
+ * Default: enabled, halt behavior, 30-min cooldown, OS-notify on.
173
+ * See dario#288.
174
+ */
175
+ overageGuardEnabled?: boolean;
176
+ overageGuardBehavior?: 'halt' | 'warn';
177
+ overageGuardCooldownMs?: number;
178
+ overageGuardNotifyOs?: boolean;
165
179
  }
166
180
  /**
167
181
  * One JSON-ND record per completed request. Field set kept narrow to