@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 +89 -1
- package/dist/config-file.d.ts +26 -0
- package/dist/config-file.js +23 -0
- package/dist/notify.d.ts +48 -0
- package/dist/notify.js +120 -0
- package/dist/overage-guard.d.ts +102 -0
- package/dist/overage-guard.js +189 -0
- package/dist/proxy.d.ts +14 -0
- package/dist/proxy.js +106 -1
- package/dist/tui/proxy-client.d.ts +44 -1
- package/dist/tui/proxy-client.js +66 -2
- package/dist/tui/tabs/analytics.js +13 -0
- package/dist/tui/tabs/config.js +5 -0
- package/dist/tui/tabs/hits.d.ts +14 -0
- package/dist/tui/tabs/hits.js +54 -4
- package/dist/tui/tabs/status.d.ts +14 -0
- package/dist/tui/tabs/status.js +109 -3
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -10,6 +10,8 @@ import { buildCCRequest, reverseMapResponse, createStreamingReverseMapper, order
|
|
|
10
10
|
import { describeTemplate, detectDrift, checkCCCompat } from './live-fingerprint.js';
|
|
11
11
|
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs } from './pool.js';
|
|
12
12
|
import { Analytics, billingBucketFromClaim } from './analytics.js';
|
|
13
|
+
import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
|
|
14
|
+
import { notify as osNotify } from './notify.js';
|
|
13
15
|
import { loadAllAccounts, loadAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale } from './accounts.js';
|
|
14
16
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
15
17
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
|
|
@@ -573,6 +575,31 @@ export async function startProxy(opts = {}) {
|
|
|
573
575
|
// endpoint, but burn-rate / per-request visibility is useful for
|
|
574
576
|
// single-account users too.
|
|
575
577
|
const analytics = new Analytics();
|
|
578
|
+
// Overage-guard (v4.1, dario#288). Resolved from opts with built-in
|
|
579
|
+
// defaults (enabled=true, behavior='halt', cooldown=30min, notifyOs=true)
|
|
580
|
+
// so an opts-less proxy still gets protection. The notifier is wired
|
|
581
|
+
// separately below once notify.ts is loaded.
|
|
582
|
+
const overageGuard = new OverageGuard({
|
|
583
|
+
enabled: opts.overageGuardEnabled ?? true,
|
|
584
|
+
behavior: opts.overageGuardBehavior ?? 'halt',
|
|
585
|
+
cooldownMs: opts.overageGuardCooldownMs ?? 30 * 60 * 1000,
|
|
586
|
+
notifyOs: opts.overageGuardNotifyOs ?? true,
|
|
587
|
+
notifier: osNotify,
|
|
588
|
+
});
|
|
589
|
+
overageGuard.attach(analytics);
|
|
590
|
+
// Surface halt + resume to the foreground startup banner so an
|
|
591
|
+
// operator running `dario proxy` directly sees the event even without
|
|
592
|
+
// a TUI attached. -v / --verbose is not required — this is loud by
|
|
593
|
+
// design.
|
|
594
|
+
overageGuard.on('halt', (state) => {
|
|
595
|
+
console.error(`[dario] OVERAGE-GUARD HALTED: ${state.request.model} on account=${state.request.account} returned representative-claim=overage at ${new Date(state.request.timestamp).toISOString()}. Returning 503 to new requests until \`dario resume\` or cooldown expires (${new Date(state.cooldownUntil).toISOString()}). See dario#288.`);
|
|
596
|
+
});
|
|
597
|
+
overageGuard.on('warn', (state) => {
|
|
598
|
+
console.error(`[dario] OVERAGE-GUARD WARN: ${state.request.model} on account=${state.request.account} returned representative-claim=overage at ${new Date(state.request.timestamp).toISOString()}. Behavior=warn — proxy continuing to forward; investigate before bill bleeds. See dario#288.`);
|
|
599
|
+
});
|
|
600
|
+
overageGuard.on('resume', (info) => {
|
|
601
|
+
console.error(`[dario] overage-guard resumed (${info.reason}). Normal request handling restored.`);
|
|
602
|
+
});
|
|
576
603
|
let status;
|
|
577
604
|
if (pool) {
|
|
578
605
|
for (const acc of accountsList) {
|
|
@@ -955,7 +982,16 @@ export async function startProxy(opts = {}) {
|
|
|
955
982
|
for (const past of analytics.recent(50)) {
|
|
956
983
|
res.write(`data: ${JSON.stringify(past)}\n\n`);
|
|
957
984
|
}
|
|
958
|
-
//
|
|
985
|
+
// Backlog the current halt state if any — a TUI attaching mid-halt
|
|
986
|
+
// needs to see the banner immediately without waiting for the
|
|
987
|
+
// next overage hit (which won't come, because the proxy is halted).
|
|
988
|
+
const haltedNow = overageGuard.state();
|
|
989
|
+
if (haltedNow) {
|
|
990
|
+
res.write(`event: overage_halt\ndata: ${JSON.stringify(haltedNow)}\n\n`);
|
|
991
|
+
}
|
|
992
|
+
// Live tail — request records on default 'message' event, halt /
|
|
993
|
+
// warn / resume on named events so the TUI can route on event type
|
|
994
|
+
// without changing the existing record shape.
|
|
959
995
|
const onRecord = (r) => {
|
|
960
996
|
// Use try/catch so a broken socket (peer hung up between events)
|
|
961
997
|
// doesn't crash the request hot-path — Analytics already wraps
|
|
@@ -965,7 +1001,28 @@ export async function startProxy(opts = {}) {
|
|
|
965
1001
|
}
|
|
966
1002
|
catch { /* ignored */ }
|
|
967
1003
|
};
|
|
1004
|
+
const onHalt = (state) => {
|
|
1005
|
+
try {
|
|
1006
|
+
res.write(`event: overage_halt\ndata: ${JSON.stringify(state)}\n\n`);
|
|
1007
|
+
}
|
|
1008
|
+
catch { /* ignored */ }
|
|
1009
|
+
};
|
|
1010
|
+
const onWarn = (state) => {
|
|
1011
|
+
try {
|
|
1012
|
+
res.write(`event: overage_warn\ndata: ${JSON.stringify(state)}\n\n`);
|
|
1013
|
+
}
|
|
1014
|
+
catch { /* ignored */ }
|
|
1015
|
+
};
|
|
1016
|
+
const onResume = (info) => {
|
|
1017
|
+
try {
|
|
1018
|
+
res.write(`event: overage_resume\ndata: ${JSON.stringify(info)}\n\n`);
|
|
1019
|
+
}
|
|
1020
|
+
catch { /* ignored */ }
|
|
1021
|
+
};
|
|
968
1022
|
analytics.on('record', onRecord);
|
|
1023
|
+
overageGuard.on('halt', onHalt);
|
|
1024
|
+
overageGuard.on('warn', onWarn);
|
|
1025
|
+
overageGuard.on('resume', onResume);
|
|
969
1026
|
// Heartbeat every 25s — SSE comments are ignored by clients but
|
|
970
1027
|
// keep middle-boxes (CDNs, dev-proxies) from closing the pipe.
|
|
971
1028
|
const heartbeat = setInterval(() => {
|
|
@@ -977,10 +1034,39 @@ export async function startProxy(opts = {}) {
|
|
|
977
1034
|
heartbeat.unref?.();
|
|
978
1035
|
req.on('close', () => {
|
|
979
1036
|
analytics.off('record', onRecord);
|
|
1037
|
+
overageGuard.off('halt', onHalt);
|
|
1038
|
+
overageGuard.off('warn', onWarn);
|
|
1039
|
+
overageGuard.off('resume', onResume);
|
|
980
1040
|
clearInterval(heartbeat);
|
|
981
1041
|
});
|
|
982
1042
|
return;
|
|
983
1043
|
}
|
|
1044
|
+
// POST /admin/resume — clear overage-guard halt state (v4.1, dario#288).
|
|
1045
|
+
// Idempotent: returns 200 with `wasHalted: false` if the proxy is
|
|
1046
|
+
// already running normally. Auth gating is the same as every other
|
|
1047
|
+
// endpoint (loopback-bind by default; DARIO_API_KEY needed for
|
|
1048
|
+
// non-loopback). GET returns the current state for read-only queries.
|
|
1049
|
+
if (urlPath === '/admin/resume' && req.method === 'GET') {
|
|
1050
|
+
const state = overageGuard.state();
|
|
1051
|
+
res.writeHead(200, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
|
|
1052
|
+
res.end(JSON.stringify({
|
|
1053
|
+
halted: state !== null,
|
|
1054
|
+
state,
|
|
1055
|
+
config: overageGuard.config(),
|
|
1056
|
+
}));
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (urlPath === '/admin/resume' && req.method === 'POST') {
|
|
1060
|
+
const wasHalted = overageGuard.state() !== null;
|
|
1061
|
+
overageGuard.clear('manual');
|
|
1062
|
+
res.writeHead(200, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
|
|
1063
|
+
res.end(JSON.stringify({
|
|
1064
|
+
ok: true,
|
|
1065
|
+
wasHalted,
|
|
1066
|
+
resumedAt: new Date().toISOString(),
|
|
1067
|
+
}));
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
984
1070
|
if (urlPath === '/v1/models' && req.method === 'GET') {
|
|
985
1071
|
requestCount++;
|
|
986
1072
|
res.writeHead(200, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
|
|
@@ -1006,6 +1092,25 @@ export async function startProxy(opts = {}) {
|
|
|
1006
1092
|
res.end(ERR_METHOD);
|
|
1007
1093
|
return;
|
|
1008
1094
|
}
|
|
1095
|
+
// Overage-guard halt check (v4.1, dario#288). Subscribers should never
|
|
1096
|
+
// see a single `representative-claim: overage` response during normal
|
|
1097
|
+
// operation; one means traffic is being reclassified to per-token
|
|
1098
|
+
// billing. Block upstream forwarding with a 503 + Anthropic-shaped
|
|
1099
|
+
// error body until the user runs `dario resume` or the cooldown
|
|
1100
|
+
// auto-expires. Health / status / analytics / admin endpoints above
|
|
1101
|
+
// bypass this check intentionally — the TUI needs them to surface
|
|
1102
|
+
// the halt and the user needs /admin/resume to clear it.
|
|
1103
|
+
if (overageGuard.isHalted()) {
|
|
1104
|
+
requestCount++;
|
|
1105
|
+
const state = overageGuard.state();
|
|
1106
|
+
writeLogLine(logFileStream, {
|
|
1107
|
+
ts: new Date().toISOString(), req: requestCount,
|
|
1108
|
+
method: req.method ?? '', path: urlPath, status: 503, reject: 'overage-halt',
|
|
1109
|
+
});
|
|
1110
|
+
res.writeHead(503, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
|
|
1111
|
+
res.end(JSON.stringify(buildHaltErrorBody(state)));
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1009
1114
|
// Proxy to Anthropic (with concurrency control). The bounded queue
|
|
1010
1115
|
// replaces the v3.30.x-and-earlier unbounded semaphore — dario#80. A
|
|
1011
1116
|
// queue-full condition returns an explicit 429 with a `"queue-full"`
|
|
@@ -48,10 +48,53 @@ export declare class ProxyClient {
|
|
|
48
48
|
* Auto-reconnect is intentionally NOT included. The Hits tab decides
|
|
49
49
|
* when to retry (and how often) — pushing that policy into here would
|
|
50
50
|
* couple the client to UI semantics.
|
|
51
|
+
*
|
|
52
|
+
* v4.1 (dario#288): the proxy emits named events alongside the default
|
|
53
|
+
* 'message' event — `event: overage_halt`, `event: overage_warn`,
|
|
54
|
+
* `event: overage_resume`. The `eventType` passed to `onMessage` is
|
|
55
|
+
* the value of the `event:` line on the frame (or `'message'` for an
|
|
56
|
+
* unlabeled / default frame). Existing consumers that pass a
|
|
57
|
+
* single-arg callback continue to work unchanged.
|
|
58
|
+
*/
|
|
59
|
+
subscribeAnalyticsStream<T = unknown>(onMessage: (msg: T, eventType?: string) => void, onError?: (err: Error) => void): () => void;
|
|
60
|
+
/**
|
|
61
|
+
* Query the overage-guard state (v4.1, dario#288). Returns the current
|
|
62
|
+
* halt state + configuration. Returns null on any error so the Status
|
|
63
|
+
* tab can render "unknown" without crashing.
|
|
51
64
|
*/
|
|
52
|
-
|
|
65
|
+
getOverageGuard(): Promise<OverageGuardStatus | null>;
|
|
66
|
+
/**
|
|
67
|
+
* Clear the overage-guard halt state. POSTs /admin/resume. Returns the
|
|
68
|
+
* server's response (`wasHalted` indicates whether the call actually
|
|
69
|
+
* cleared a halt vs no-op'd on already-clear state).
|
|
70
|
+
*/
|
|
71
|
+
resume(): Promise<{
|
|
72
|
+
ok: boolean;
|
|
73
|
+
wasHalted: boolean;
|
|
74
|
+
resumedAt: string;
|
|
75
|
+
}>;
|
|
53
76
|
private headers;
|
|
54
77
|
}
|
|
78
|
+
export interface OverageGuardStatus {
|
|
79
|
+
halted: boolean;
|
|
80
|
+
state: {
|
|
81
|
+
since: number;
|
|
82
|
+
cooldownUntil: number;
|
|
83
|
+
reason: string;
|
|
84
|
+
request: {
|
|
85
|
+
timestamp: number;
|
|
86
|
+
model: string;
|
|
87
|
+
account: string;
|
|
88
|
+
claim: string;
|
|
89
|
+
};
|
|
90
|
+
} | null;
|
|
91
|
+
config: {
|
|
92
|
+
enabled: boolean;
|
|
93
|
+
behavior: 'halt' | 'warn';
|
|
94
|
+
cooldownMs: number;
|
|
95
|
+
notifyOs: boolean;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
55
98
|
export interface HealthResponse {
|
|
56
99
|
status: string;
|
|
57
100
|
oauth: string;
|
package/dist/tui/proxy-client.js
CHANGED
|
@@ -86,6 +86,13 @@ export class ProxyClient {
|
|
|
86
86
|
* Auto-reconnect is intentionally NOT included. The Hits tab decides
|
|
87
87
|
* when to retry (and how often) — pushing that policy into here would
|
|
88
88
|
* couple the client to UI semantics.
|
|
89
|
+
*
|
|
90
|
+
* v4.1 (dario#288): the proxy emits named events alongside the default
|
|
91
|
+
* 'message' event — `event: overage_halt`, `event: overage_warn`,
|
|
92
|
+
* `event: overage_resume`. The `eventType` passed to `onMessage` is
|
|
93
|
+
* the value of the `event:` line on the frame (or `'message'` for an
|
|
94
|
+
* unlabeled / default frame). Existing consumers that pass a
|
|
95
|
+
* single-arg callback continue to work unchanged.
|
|
89
96
|
*/
|
|
90
97
|
subscribeAnalyticsStream(onMessage, onError) {
|
|
91
98
|
const url = new URL(this.baseUrl + '/analytics/stream');
|
|
@@ -121,15 +128,19 @@ export class ProxyClient {
|
|
|
121
128
|
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
122
129
|
const frame = buf.slice(0, idx);
|
|
123
130
|
buf = buf.slice(idx + 2);
|
|
124
|
-
const
|
|
131
|
+
const lines = frame.split('\n');
|
|
132
|
+
const dataLines = lines
|
|
125
133
|
.filter(l => l.startsWith('data:'))
|
|
126
134
|
.map(l => l.slice(5).replace(/^ /, ''));
|
|
127
135
|
if (dataLines.length === 0)
|
|
128
136
|
continue;
|
|
137
|
+
// Pull the `event:` line if present. Default is 'message' per SSE spec.
|
|
138
|
+
const eventLine = lines.find(l => l.startsWith('event:'));
|
|
139
|
+
const eventType = eventLine ? eventLine.slice(6).trim() : 'message';
|
|
129
140
|
const payload = dataLines.join('\n');
|
|
130
141
|
try {
|
|
131
142
|
const parsed = JSON.parse(payload);
|
|
132
|
-
onMessage(parsed);
|
|
143
|
+
onMessage(parsed, eventType);
|
|
133
144
|
}
|
|
134
145
|
catch (e) {
|
|
135
146
|
onError?.(new Error(`SSE parse: ${e.message}`));
|
|
@@ -157,6 +168,59 @@ export class ProxyClient {
|
|
|
157
168
|
catch { /* ignored */ }
|
|
158
169
|
};
|
|
159
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Query the overage-guard state (v4.1, dario#288). Returns the current
|
|
173
|
+
* halt state + configuration. Returns null on any error so the Status
|
|
174
|
+
* tab can render "unknown" without crashing.
|
|
175
|
+
*/
|
|
176
|
+
async getOverageGuard() {
|
|
177
|
+
try {
|
|
178
|
+
return await this.getJson('/admin/resume');
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Clear the overage-guard halt state. POSTs /admin/resume. Returns the
|
|
186
|
+
* server's response (`wasHalted` indicates whether the call actually
|
|
187
|
+
* cleared a halt vs no-op'd on already-clear state).
|
|
188
|
+
*/
|
|
189
|
+
async resume() {
|
|
190
|
+
const url = new URL(this.baseUrl + '/admin/resume');
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
const req = httpRequest({
|
|
193
|
+
hostname: url.hostname,
|
|
194
|
+
port: url.port || 80,
|
|
195
|
+
path: url.pathname,
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { ...this.headers(), 'Content-Type': 'application/json', 'Content-Length': '2' },
|
|
198
|
+
}, (res) => {
|
|
199
|
+
const chunks = [];
|
|
200
|
+
res.on('data', (c) => chunks.push(c));
|
|
201
|
+
res.on('end', () => {
|
|
202
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
203
|
+
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
204
|
+
reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
resolve(JSON.parse(body));
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
reject(new Error(`JSON parse: ${e.message}`));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
res.on('error', reject);
|
|
215
|
+
});
|
|
216
|
+
req.on('error', reject);
|
|
217
|
+
req.setTimeout(this.timeoutMs, () => {
|
|
218
|
+
req.destroy(new Error(`timeout after ${this.timeoutMs}ms`));
|
|
219
|
+
});
|
|
220
|
+
req.write('{}');
|
|
221
|
+
req.end();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
160
224
|
headers() {
|
|
161
225
|
const h = {};
|
|
162
226
|
if (this.apiKey)
|
|
@@ -102,6 +102,19 @@ export const AnalyticsTab = {
|
|
|
102
102
|
lines.push(' ' + pad('7d', 6) +
|
|
103
103
|
fg('cyan', progressBar(s.utilization.lastUtil7d, barWidth)) +
|
|
104
104
|
' ' + dim(`${(s.utilization.lastUtil7d * 100).toFixed(0)}%`));
|
|
105
|
+
// Overage bucket (v4.1, dario#288). Count of requests that landed in
|
|
106
|
+
// the overage bucket within the rolling window. Empty bar in normal
|
|
107
|
+
// operation; non-zero count renders in red. Hard zero IS the success
|
|
108
|
+
// signal here — anything else is "investigate immediately."
|
|
109
|
+
const overageCount = s.window.billingBucketBreakdown?.extra_usage ?? 0;
|
|
110
|
+
const totalCount = Object.values(s.window.billingBucketBreakdown ?? {}).reduce((a, b) => a + b, 0);
|
|
111
|
+
const overageFrac = totalCount > 0 ? overageCount / totalCount : 0;
|
|
112
|
+
const overageColor = overageCount > 0 ? 'red' : 'cyan';
|
|
113
|
+
lines.push(' ' + pad('Overage', 6) +
|
|
114
|
+
fg(overageColor, progressBar(overageFrac, barWidth)) +
|
|
115
|
+
' ' + (overageCount > 0
|
|
116
|
+
? fg('red', `${overageCount} req`) + dim(` of ${totalCount}`)
|
|
117
|
+
: dim('0 ← clean')));
|
|
105
118
|
// ── Billing buckets ───────────────────────────────────────
|
|
106
119
|
const buckets = s.window.billingBucketBreakdown;
|
|
107
120
|
const totalBucketCount = Object.values(buckets).reduce((a, b) => a + b, 0);
|
package/dist/tui/tabs/config.js
CHANGED
|
@@ -38,6 +38,11 @@ const FIELDS = [
|
|
|
38
38
|
{ path: 'thinkTime.maxMs', label: 'Think-time cap (ms)', type: 'number', hint: 'upper bound for the whole formula' },
|
|
39
39
|
{ path: 'sessionStart.minMs', label: 'Session-start min', type: 'number', hint: 'first-request delay floor' },
|
|
40
40
|
{ path: 'sessionStart.jitterMs', label: 'Session-start jitter', type: 'number' },
|
|
41
|
+
// ── Overage-guard (v4.1, dario#288) ─────────────────────────
|
|
42
|
+
{ path: 'overageGuard.enabled', label: 'Overage-guard', type: 'bool', hint: 'halt proxy on any representative-claim=overage' },
|
|
43
|
+
{ path: 'overageGuard.behavior', label: 'Overage behavior', type: 'string', hint: '"halt" (default) or "warn"' },
|
|
44
|
+
{ path: 'overageGuard.cooldownMs', label: 'Overage cooldown (ms)', type: 'number', hint: 'auto-resume delay; default 1800000 (30 min)' },
|
|
45
|
+
{ path: 'overageGuard.notifyOs', label: 'Overage OS-notify', type: 'bool', hint: 'native desktop notification on halt' },
|
|
41
46
|
];
|
|
42
47
|
export const ConfigTab = {
|
|
43
48
|
id: 'config',
|
package/dist/tui/tabs/hits.d.ts
CHANGED
|
@@ -25,10 +25,24 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import type { Tab } from '../tab.js';
|
|
27
27
|
import type { RequestRecord } from '../../analytics.js';
|
|
28
|
+
/** Live overage-halt state — populated from SSE event:overage_halt frames. */
|
|
29
|
+
interface HitsHaltState {
|
|
30
|
+
since: number;
|
|
31
|
+
cooldownUntil: number;
|
|
32
|
+
request: {
|
|
33
|
+
timestamp: number;
|
|
34
|
+
model: string;
|
|
35
|
+
account: string;
|
|
36
|
+
claim: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
28
39
|
export interface HitsState {
|
|
29
40
|
buffer: RequestRecord[];
|
|
30
41
|
selectedIdx: number;
|
|
31
42
|
subscribed: boolean;
|
|
32
43
|
connectionError: string | null;
|
|
44
|
+
/** Overage-guard halt banner (v4.1, dario#288). Null when running normally. */
|
|
45
|
+
halt: HitsHaltState | null;
|
|
33
46
|
}
|
|
34
47
|
export declare const HitsTab: Tab<HitsState>;
|
|
48
|
+
export {};
|
package/dist/tui/tabs/hits.js
CHANGED
|
@@ -32,13 +32,28 @@ export const HitsTab = {
|
|
|
32
32
|
label: 'Hits',
|
|
33
33
|
hotkey: 'h',
|
|
34
34
|
initialState() {
|
|
35
|
-
return { buffer: [], selectedIdx: -1, subscribed: false, connectionError: null };
|
|
35
|
+
return { buffer: [], selectedIdx: -1, subscribed: false, connectionError: null, halt: null };
|
|
36
36
|
},
|
|
37
37
|
onMount(_state, ctx) {
|
|
38
38
|
// Subscribe to the live stream. Each record is prepended-conceptually
|
|
39
39
|
// (we push to the array and render in reverse, which keeps the
|
|
40
40
|
// buffer's mutation simple — Array.push is O(1) while unshift is O(n)).
|
|
41
|
-
|
|
41
|
+
//
|
|
42
|
+
// The same stream carries named events for overage-halt / -resume
|
|
43
|
+
// (v4.1, dario#288). The SSE event type is the second argument; we
|
|
44
|
+
// route on it.
|
|
45
|
+
const close = ctx.client.subscribeAnalyticsStream((payload, eventType) => {
|
|
46
|
+
if (eventType === 'overage_halt' || eventType === 'overage_warn') {
|
|
47
|
+
const state = payload;
|
|
48
|
+
ctx.setState((s) => ({ ...s, halt: state }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (eventType === 'overage_resume') {
|
|
52
|
+
ctx.setState((s) => ({ ...s, halt: null }));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Default ('message') = RequestRecord
|
|
56
|
+
const record = payload;
|
|
42
57
|
ctx.setState((s) => {
|
|
43
58
|
const next = {
|
|
44
59
|
...s,
|
|
@@ -123,6 +138,16 @@ export const HitsTab = {
|
|
|
123
138
|
const colIn = 8, colOut = 7, colLat = 7, colStatus = 5;
|
|
124
139
|
lines.push(' ' + brand('Hits') +
|
|
125
140
|
dim(` ${state.buffer.length} buffered · ${state.subscribed ? fg('green', 'live') : fg('yellow', 'disconnected')}`));
|
|
141
|
+
// ── Overage-halt banner (v4.1, dario#288) ──────────────────
|
|
142
|
+
// Pinned at the top so it's always visible while scrolling the buffer.
|
|
143
|
+
if (state.halt) {
|
|
144
|
+
const since = formatTimestamp(state.halt.since);
|
|
145
|
+
const cooldown = formatRemaining(state.halt.cooldownUntil - Date.now());
|
|
146
|
+
const line1 = ` ${fg('red', '⚠ HALTED')} overage detected at ${since} on ${state.halt.request.model} (account=${state.halt.request.account})`;
|
|
147
|
+
const line2 = ` ${dim('→ New /v1/messages requests return 503 until')} ${fg('cyan', 'R')} ${dim('here, or')} ${fg('cyan', 'dario resume')}${dim(' from any shell. Auto-resume in')} ${cooldown}${dim('.')}`;
|
|
148
|
+
lines.push(line1);
|
|
149
|
+
lines.push(line2);
|
|
150
|
+
}
|
|
126
151
|
lines.push('');
|
|
127
152
|
// Header row (aligned with data rows)
|
|
128
153
|
lines.push(' ' + dim(pad('time', colTime) +
|
|
@@ -133,7 +158,10 @@ export const HitsTab = {
|
|
|
133
158
|
pad('st', colStatus)));
|
|
134
159
|
for (let i = startIdx; i < endIdx; i++) {
|
|
135
160
|
const r = newestFirst[i];
|
|
136
|
-
const
|
|
161
|
+
const isOverage = r.claim === 'overage';
|
|
162
|
+
const marker = i === state.selectedIdx ? fg('cyan', '▎')
|
|
163
|
+
: isOverage ? fg('red', '!')
|
|
164
|
+
: ' ';
|
|
137
165
|
const row = marker + ' ' +
|
|
138
166
|
pad(formatTime(r.timestamp), colTime) +
|
|
139
167
|
pad(shortenModel(r.model), colModel) +
|
|
@@ -141,7 +169,16 @@ export const HitsTab = {
|
|
|
141
169
|
pad(formatTokens(r.outputTokens), colOut) +
|
|
142
170
|
pad(formatLatency(r.latencyMs), colLat) +
|
|
143
171
|
pad(formatStatus(r.status), colStatus);
|
|
144
|
-
|
|
172
|
+
// Overage rows render in red even when unselected; selection still
|
|
173
|
+
// wins via the inverse() wrapper so the user can drill into one.
|
|
174
|
+
let styled;
|
|
175
|
+
if (i === state.selectedIdx)
|
|
176
|
+
styled = inverse(truncate(row, w - 2));
|
|
177
|
+
else if (isOverage)
|
|
178
|
+
styled = fg('red', truncate(row, w - 2));
|
|
179
|
+
else
|
|
180
|
+
styled = truncate(row, w - 2);
|
|
181
|
+
lines.push(styled);
|
|
145
182
|
}
|
|
146
183
|
// Scroll hint
|
|
147
184
|
if (newestFirst.length > listRows) {
|
|
@@ -221,3 +258,16 @@ function tokenBreakdown(r) {
|
|
|
221
258
|
parts.push(`thinking ${r.thinkingTokens}`);
|
|
222
259
|
return parts.join(' / ');
|
|
223
260
|
}
|
|
261
|
+
function formatTimestamp(ts) {
|
|
262
|
+
const d = new Date(ts);
|
|
263
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
|
264
|
+
}
|
|
265
|
+
function formatRemaining(ms) {
|
|
266
|
+
if (ms <= 0)
|
|
267
|
+
return fg('yellow', 'now');
|
|
268
|
+
const s = Math.floor(ms / 1000);
|
|
269
|
+
if (s < 60)
|
|
270
|
+
return `${s}s`;
|
|
271
|
+
const m = Math.floor(s / 60);
|
|
272
|
+
return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
273
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* └─────────────────────────────────────────────────┘
|
|
21
21
|
*/
|
|
22
22
|
import type { Tab, TabContext } from '../tab.js';
|
|
23
|
+
import type { OverageGuardStatus } from '../proxy-client.js';
|
|
23
24
|
export interface StatusState {
|
|
24
25
|
loading: boolean;
|
|
25
26
|
/** Proxy /health response, or null if unreachable. */
|
|
@@ -31,6 +32,12 @@ export interface StatusState {
|
|
|
31
32
|
} | null;
|
|
32
33
|
/** Config-file load source: file | missing | invalid. */
|
|
33
34
|
configSource: 'file' | 'missing' | 'invalid' | null;
|
|
35
|
+
/** Overage-guard state from /admin/resume — null if unreachable. */
|
|
36
|
+
overageGuard: OverageGuardStatus | null;
|
|
37
|
+
/** Transient: did we just attempt a manual resume? */
|
|
38
|
+
resumePending: boolean;
|
|
39
|
+
resumeMessage: string | null;
|
|
40
|
+
resumeKind: 'success' | 'info' | 'error' | null;
|
|
34
41
|
/** Last refresh timestamp (ms). */
|
|
35
42
|
lastRefreshAt: number;
|
|
36
43
|
/** Error from the last refresh attempt, if any. */
|
|
@@ -43,3 +50,10 @@ export declare const StatusTab: Tab<StatusState>;
|
|
|
43
50
|
* 'r' without re-running the full onMount flow.
|
|
44
51
|
*/
|
|
45
52
|
export declare function refreshStatus(ctx: TabContext): Promise<StatusState>;
|
|
53
|
+
/**
|
|
54
|
+
* Fire the manual-resume POST and update state. Called by TuiApp when the
|
|
55
|
+
* Status tab returns a state with resumePending=true (the `R` key path).
|
|
56
|
+
* Lives here next to refreshStatus so all status-tab-side-effects sit
|
|
57
|
+
* together.
|
|
58
|
+
*/
|
|
59
|
+
export declare function performResume(ctx: TabContext<StatusState>): Promise<Partial<StatusState>>;
|
package/dist/tui/tabs/status.js
CHANGED
|
@@ -30,6 +30,10 @@ export const StatusTab = {
|
|
|
30
30
|
loading: true,
|
|
31
31
|
health: null,
|
|
32
32
|
configSource: null,
|
|
33
|
+
overageGuard: null,
|
|
34
|
+
resumePending: false,
|
|
35
|
+
resumeMessage: null,
|
|
36
|
+
resumeKind: null,
|
|
33
37
|
lastRefreshAt: 0,
|
|
34
38
|
error: null,
|
|
35
39
|
};
|
|
@@ -37,12 +41,40 @@ export const StatusTab = {
|
|
|
37
41
|
async onMount(_state, ctx) {
|
|
38
42
|
return refreshStatus(ctx);
|
|
39
43
|
},
|
|
44
|
+
onTick(state, ctx) {
|
|
45
|
+
// Drive the async side-effects that onKey can't fire directly.
|
|
46
|
+
if (state.resumePending) {
|
|
47
|
+
void performResume(ctx).then((delta) => ctx.setState(delta));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Poll the overage-guard state every 2s so the halt countdown stays
|
|
51
|
+
// current without the user having to press `r`. Cheap GET; the proxy
|
|
52
|
+
// is on loopback. Skip when the rest of the status is still loading.
|
|
53
|
+
if (!state.loading && state.overageGuard !== null && state.overageGuard.halted) {
|
|
54
|
+
// While halted, refresh every 2s so the countdown updates.
|
|
55
|
+
const since = Date.now() - state.lastRefreshAt;
|
|
56
|
+
if (since >= 2000) {
|
|
57
|
+
void ctx.client.getOverageGuard().then((g) => {
|
|
58
|
+
if (g)
|
|
59
|
+
ctx.setState({ overageGuard: g, lastRefreshAt: Date.now() });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
40
64
|
onKey(state, key) {
|
|
41
|
-
// `r` triggers a manual refresh
|
|
42
|
-
// onMount again. The parent watches for a sentinel state.
|
|
65
|
+
// `r` triggers a manual refresh — signal the parent to call onMount again.
|
|
43
66
|
if (key.name === 'printable' && key.ch === 'r' && !key.ctrl) {
|
|
44
67
|
return { ...state, loading: true };
|
|
45
68
|
}
|
|
69
|
+
// `R` (shift-r) resumes the overage-guard halt state when one is active.
|
|
70
|
+
// Returning a sentinel state with resumePending=true signals the parent
|
|
71
|
+
// to fire the async resume() call.
|
|
72
|
+
if (key.name === 'printable' && key.ch === 'R' && !key.ctrl) {
|
|
73
|
+
if (state.overageGuard?.halted) {
|
|
74
|
+
return { ...state, resumePending: true, resumeMessage: 'Resuming…', resumeKind: 'info' };
|
|
75
|
+
}
|
|
76
|
+
return { ...state, resumeMessage: 'Nothing to resume — proxy is not halted.', resumeKind: 'info' };
|
|
77
|
+
}
|
|
46
78
|
return undefined;
|
|
47
79
|
},
|
|
48
80
|
render(state, dim_) {
|
|
@@ -75,12 +107,52 @@ export const StatusTab = {
|
|
|
75
107
|
: dim('not loaded');
|
|
76
108
|
lines.push(' ' + renderKvRow('Source', sourceLabel, w - 4));
|
|
77
109
|
lines.push('');
|
|
110
|
+
// ── Overage-guard section (v4.1, dario#288) ────────────────
|
|
111
|
+
if (state.overageGuard) {
|
|
112
|
+
lines.push(' ' + brand('Overage-guard'));
|
|
113
|
+
if (state.overageGuard.halted && state.overageGuard.state) {
|
|
114
|
+
const s = state.overageGuard.state;
|
|
115
|
+
const remainingMs = Math.max(0, s.cooldownUntil - Date.now());
|
|
116
|
+
const remaining = formatDuration(remainingMs);
|
|
117
|
+
// Red banner header — this is the loud surface when halted
|
|
118
|
+
lines.push(' ' + fg('red', '⚠ HALTED') + ' ' + dim(`overage detected ${formatAgo(s.since)} ago`));
|
|
119
|
+
lines.push(' ' + renderKvRow('Request', `${s.request.model} ${dim('account=' + s.request.account)}`, w - 4));
|
|
120
|
+
lines.push(' ' + renderKvRow('Cause', `representative-claim = ${fg('red', s.request.claim)}`, w - 4));
|
|
121
|
+
lines.push(' ' + renderKvRow('Auto-resume in', remaining === '0s' ? fg('yellow', 'now (cooldown elapsed)') : remaining, w - 4));
|
|
122
|
+
lines.push(' ' + renderKvRow('Manual resume', `press ${fg('cyan', 'R')} here, or ${fg('cyan', 'dario resume')} from any shell`, w - 4));
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
lines.push(' ' + renderKvRow('State', fg('green', 'normal'), w - 4));
|
|
126
|
+
const cfg = state.overageGuard.config;
|
|
127
|
+
lines.push(' ' + renderKvRow('Mode', `${cfg.enabled ? fg('green', 'enabled') : fg('yellow', 'disabled')} ${dim(`behavior=${cfg.behavior} cooldown=${formatDuration(cfg.cooldownMs)}`)}`, w - 4));
|
|
128
|
+
}
|
|
129
|
+
if (state.resumeMessage) {
|
|
130
|
+
const c = state.resumeKind === 'error' ? 'red' : state.resumeKind === 'success' ? 'green' : 'cyan';
|
|
131
|
+
lines.push(' ' + fg(c, state.resumeMessage));
|
|
132
|
+
}
|
|
133
|
+
lines.push('');
|
|
134
|
+
}
|
|
78
135
|
// ── Footer hint ────────────────────────────────────────────
|
|
79
136
|
lines.push('');
|
|
80
|
-
|
|
137
|
+
const resumeHint = state.overageGuard?.halted ? ` · ${fg('cyan', 'R')} resume` : '';
|
|
138
|
+
lines.push(' ' + dim(`Last refresh: ${formatAgo(state.lastRefreshAt)}. ${fg('cyan', 'r')} refresh${resumeHint}.`));
|
|
81
139
|
return lines.join('\n');
|
|
82
140
|
},
|
|
83
141
|
};
|
|
142
|
+
function formatDuration(ms) {
|
|
143
|
+
if (ms <= 0)
|
|
144
|
+
return '0s';
|
|
145
|
+
const s = Math.floor(ms / 1000);
|
|
146
|
+
if (s < 60)
|
|
147
|
+
return `${s}s`;
|
|
148
|
+
const m = Math.floor(s / 60);
|
|
149
|
+
const rs = s % 60;
|
|
150
|
+
if (m < 60)
|
|
151
|
+
return rs > 0 ? `${m}m ${rs}s` : `${m}m`;
|
|
152
|
+
const h = Math.floor(m / 60);
|
|
153
|
+
const rm = m % 60;
|
|
154
|
+
return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
|
|
155
|
+
}
|
|
84
156
|
/**
|
|
85
157
|
* Refresh the Status tab's data — probe /health, load config file
|
|
86
158
|
* metadata. Exported separately so the parent can re-invoke on key
|
|
@@ -98,14 +170,48 @@ export async function refreshStatus(ctx) {
|
|
|
98
170
|
catch (e) {
|
|
99
171
|
error = e.message;
|
|
100
172
|
}
|
|
173
|
+
// Overage-guard state — best-effort; never throws (proxy-client wraps the
|
|
174
|
+
// GET in try/catch and returns null). Surface as 'unknown' when null.
|
|
175
|
+
const overageGuard = await ctx.client.getOverageGuard();
|
|
101
176
|
return {
|
|
102
177
|
loading: false,
|
|
103
178
|
health,
|
|
104
179
|
configSource: fileResult.source,
|
|
180
|
+
overageGuard,
|
|
181
|
+
resumePending: false,
|
|
182
|
+
resumeMessage: null,
|
|
183
|
+
resumeKind: null,
|
|
105
184
|
lastRefreshAt: Date.now(),
|
|
106
185
|
error,
|
|
107
186
|
};
|
|
108
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Fire the manual-resume POST and update state. Called by TuiApp when the
|
|
190
|
+
* Status tab returns a state with resumePending=true (the `R` key path).
|
|
191
|
+
* Lives here next to refreshStatus so all status-tab-side-effects sit
|
|
192
|
+
* together.
|
|
193
|
+
*/
|
|
194
|
+
export async function performResume(ctx) {
|
|
195
|
+
try {
|
|
196
|
+
const result = await ctx.client.resume();
|
|
197
|
+
const refreshed = await ctx.client.getOverageGuard();
|
|
198
|
+
return {
|
|
199
|
+
overageGuard: refreshed,
|
|
200
|
+
resumePending: false,
|
|
201
|
+
resumeMessage: result.wasHalted
|
|
202
|
+
? `Resumed at ${result.resumedAt}.`
|
|
203
|
+
: 'Already running normally — no-op.',
|
|
204
|
+
resumeKind: 'success',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
return {
|
|
209
|
+
resumePending: false,
|
|
210
|
+
resumeMessage: `Resume failed: ${e.message}`,
|
|
211
|
+
resumeKind: 'error',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
109
215
|
function formatOauth(label, expiresIn) {
|
|
110
216
|
if (label === 'healthy') {
|
|
111
217
|
return fg('green', expiresIn ? `healthy (expires in ${expiresIn})` : 'healthy');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.1.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": {
|