@askalf/dario 3.38.6 → 4.0.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/README.md +23 -1
- package/dist/analytics.d.ts +32 -3
- package/dist/analytics.js +48 -3
- package/dist/cli.js +111 -25
- package/dist/config-file.d.ts +157 -0
- package/dist/config-file.js +326 -0
- package/dist/proxy.js +93 -23
- package/dist/tui/app.d.ts +96 -0
- package/dist/tui/app.js +178 -0
- package/dist/tui/input.d.ts +57 -0
- package/dist/tui/input.js +206 -0
- package/dist/tui/layout.d.ts +66 -0
- package/dist/tui/layout.js +152 -0
- package/dist/tui/proxy-client.d.ts +60 -0
- package/dist/tui/proxy-client.js +166 -0
- package/dist/tui/render.d.ts +178 -0
- package/dist/tui/render.js +246 -0
- package/dist/tui/tab.d.ts +89 -0
- package/dist/tui/tab.js +19 -0
- package/dist/tui/tabs/accounts.d.ts +32 -0
- package/dist/tui/tabs/accounts.js +110 -0
- package/dist/tui/tabs/analytics.d.ts +53 -0
- package/dist/tui/tabs/analytics.js +161 -0
- package/dist/tui/tabs/backends.d.ts +19 -0
- package/dist/tui/tabs/backends.js +77 -0
- package/dist/tui/tabs/config.d.ts +35 -0
- package/dist/tui/tabs/config.js +267 -0
- package/dist/tui/tabs/hits.d.ts +34 -0
- package/dist/tui/tabs/hits.js +223 -0
- package/dist/tui/tabs/status.d.ts +45 -0
- package/dist/tui/tabs/status.js +132 -0
- package/dist/tui/tui-app.d.ts +41 -0
- package/dist/tui/tui-app.js +217 -0
- package/package.json +1 -1
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config file foundation — v4.
|
|
3
|
+
*
|
|
4
|
+
* Persists user-tunable settings to `~/.dario/config.json` so the TUI
|
|
5
|
+
* can read + write them without having to manage shell scripts. Establishes
|
|
6
|
+
* the precedence chain that every effective value passes through:
|
|
7
|
+
*
|
|
8
|
+
* defaults < config.json < env var < CLI flag
|
|
9
|
+
*
|
|
10
|
+
* Existing CLI flags + env vars continue to work unchanged. The config
|
|
11
|
+
* file is purely additive — a missing file resolves to defaults, exactly
|
|
12
|
+
* as v3 already behaved (since v3 had no config file).
|
|
13
|
+
*
|
|
14
|
+
* Atomic write: write to `config.json.tmp`, fsync, rename. Same primitive
|
|
15
|
+
* shape `atomicWriteJson` in src/live-fingerprint.ts uses for the
|
|
16
|
+
* captured CC template.
|
|
17
|
+
*
|
|
18
|
+
* Unknown keys in the loaded file are preserved (forward-compat for
|
|
19
|
+
* future schema versions); validation is best-effort, not strict — a
|
|
20
|
+
* corrupt or partial file falls back to defaults rather than aborting
|
|
21
|
+
* the process.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
/**
|
|
27
|
+
* Bumped on any incompatible shape change. v4.0.0 ships schema v1. A
|
|
28
|
+
* future shape change would either add a new optional field (no bump)
|
|
29
|
+
* or rename / restructure (bump to v2 with a migration in `loadConfig`).
|
|
30
|
+
*/
|
|
31
|
+
export const CONFIG_SCHEMA_VERSION = 1;
|
|
32
|
+
/**
|
|
33
|
+
* Default `~/.dario/config.json` location. Override in tests via
|
|
34
|
+
* `loadConfig(path)` / `saveConfig(path, …)`.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_CONFIG_PATH = join(homedir(), '.dario', 'config.json');
|
|
37
|
+
/**
|
|
38
|
+
* Defaults match the v3.x CLI flag defaults exactly. Any value not
|
|
39
|
+
* specified in config.json resolves to its corresponding default here.
|
|
40
|
+
* Updates to a flag default MUST land here too so they stay in sync.
|
|
41
|
+
*/
|
|
42
|
+
export function defaultConfig() {
|
|
43
|
+
return {
|
|
44
|
+
version: CONFIG_SCHEMA_VERSION,
|
|
45
|
+
port: 3456,
|
|
46
|
+
host: '127.0.0.1',
|
|
47
|
+
model: null,
|
|
48
|
+
passthrough: false,
|
|
49
|
+
preserveTools: false,
|
|
50
|
+
hybridTools: false,
|
|
51
|
+
mergeTools: false,
|
|
52
|
+
noAutoDetect: false,
|
|
53
|
+
strictTls: false,
|
|
54
|
+
strictTemplate: false,
|
|
55
|
+
noLiveCapture: false,
|
|
56
|
+
drainOnClose: false,
|
|
57
|
+
stealth: false,
|
|
58
|
+
pacing: { minMs: 500, jitterMs: 0 },
|
|
59
|
+
thinkTime: { baseMs: 0, perTokenMs: 0, jitterMs: 0, maxMs: 30_000 },
|
|
60
|
+
sessionStart: { minMs: 0, jitterMs: 0 },
|
|
61
|
+
session: {
|
|
62
|
+
idleRotateMs: 900_000,
|
|
63
|
+
rotateJitterMs: 0,
|
|
64
|
+
maxAgeMs: null,
|
|
65
|
+
perClient: false,
|
|
66
|
+
},
|
|
67
|
+
queue: { maxConcurrent: null, maxQueued: null, timeoutMs: null },
|
|
68
|
+
effort: null,
|
|
69
|
+
maxTokens: null,
|
|
70
|
+
passthroughBetas: [],
|
|
71
|
+
systemPrompt: null,
|
|
72
|
+
preserveOrchestrationTags: false,
|
|
73
|
+
logFile: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Load the config file at `path` (default ~/.dario/config.json).
|
|
78
|
+
*
|
|
79
|
+
* Returns `{ config, source }` where `source` describes the load outcome
|
|
80
|
+
* for the caller's UI:
|
|
81
|
+
*
|
|
82
|
+
* - 'file' — successfully loaded
|
|
83
|
+
* - 'missing' — file doesn't exist; defaults returned (not an error)
|
|
84
|
+
* - 'invalid' — file exists but parse / shape check failed; defaults
|
|
85
|
+
* returned. The TUI surfaces this so the user knows
|
|
86
|
+
* their saved settings were ignored.
|
|
87
|
+
*
|
|
88
|
+
* The loaded shape is type-checked field-by-field: unknown keys pass
|
|
89
|
+
* through (forward-compat), known keys with wrong types are dropped.
|
|
90
|
+
* Strict validation would force a config migration on every shape
|
|
91
|
+
* tweak; loose-but-typed lets the file evolve without breaking older
|
|
92
|
+
* dario installs that haven't been restarted.
|
|
93
|
+
*/
|
|
94
|
+
export function loadConfig(path = DEFAULT_CONFIG_PATH) {
|
|
95
|
+
if (!existsSync(path)) {
|
|
96
|
+
return { config: defaultConfig(), source: 'missing' };
|
|
97
|
+
}
|
|
98
|
+
let raw;
|
|
99
|
+
try {
|
|
100
|
+
raw = readFileSync(path, 'utf-8');
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
return {
|
|
104
|
+
config: defaultConfig(),
|
|
105
|
+
source: 'invalid',
|
|
106
|
+
error: `read failed: ${err.message}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(raw);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
return {
|
|
115
|
+
config: defaultConfig(),
|
|
116
|
+
source: 'invalid',
|
|
117
|
+
error: `JSON parse failed: ${err.message}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (!isPlainObject(parsed)) {
|
|
121
|
+
return {
|
|
122
|
+
config: defaultConfig(),
|
|
123
|
+
source: 'invalid',
|
|
124
|
+
error: `top-level value is not an object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Future schema bumps: dispatch on parsed.version here and run the
|
|
128
|
+
// appropriate migration. For now we accept any version field but
|
|
129
|
+
// pass the rest through field-by-field validation.
|
|
130
|
+
const typed = sanitize(parsed);
|
|
131
|
+
// Merge over defaults so callers always get a fully-populated shape.
|
|
132
|
+
return {
|
|
133
|
+
config: mergeOver(defaultConfig(), typed),
|
|
134
|
+
source: 'file',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Atomically write `config` to `path`. Writes to `<path>.tmp`, then
|
|
139
|
+
* renames into place — guarantees a reader never observes a half-written
|
|
140
|
+
* file. Creates parent directories if missing.
|
|
141
|
+
*
|
|
142
|
+
* Throws on permission / disk failures (caller handles + surfaces to
|
|
143
|
+
* the TUI's status line). Does NOT throw on a no-op rewrite of the
|
|
144
|
+
* same content; that's a cheap idempotent path.
|
|
145
|
+
*/
|
|
146
|
+
export function saveConfig(path = DEFAULT_CONFIG_PATH, config) {
|
|
147
|
+
const parent = dirname(path);
|
|
148
|
+
if (!existsSync(parent)) {
|
|
149
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
150
|
+
}
|
|
151
|
+
const json = JSON.stringify({ ...config, version: CONFIG_SCHEMA_VERSION }, null, 2) + '\n';
|
|
152
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
153
|
+
try {
|
|
154
|
+
writeFileSync(tmp, json, { mode: 0o600 });
|
|
155
|
+
renameSync(tmp, path);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
// Best-effort cleanup of the temp file so we don't leave debris.
|
|
159
|
+
try {
|
|
160
|
+
unlinkSync(tmp);
|
|
161
|
+
}
|
|
162
|
+
catch { /* ignore */ }
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Deep-merge `over` into `base`, preferring `over` values where defined.
|
|
168
|
+
* Nested objects are merged recursively; arrays and primitives are
|
|
169
|
+
* replaced wholesale (no array-element merge — that'd be surprising).
|
|
170
|
+
*
|
|
171
|
+
* `undefined` in `over` is treated as "absent" and falls through to
|
|
172
|
+
* the `base` value. `null` is a real value and overrides.
|
|
173
|
+
*/
|
|
174
|
+
export function mergeOver(base, over) {
|
|
175
|
+
const out = { ...base };
|
|
176
|
+
for (const [k, v] of Object.entries(over)) {
|
|
177
|
+
if (v === undefined)
|
|
178
|
+
continue;
|
|
179
|
+
if (isPlainObject(v) && isPlainObject(out[k])) {
|
|
180
|
+
// Recurse into nested object groups (pacing, thinkTime, …) so a
|
|
181
|
+
// partial override on one sub-field doesn't wipe siblings.
|
|
182
|
+
out[k] = mergeOver(out[k], v);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
out[k] = v;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Resolve the effective config: load file, layer env vars on top, layer
|
|
192
|
+
* CLI flags on top.
|
|
193
|
+
*
|
|
194
|
+
* defaults < config.json < env < cli
|
|
195
|
+
*
|
|
196
|
+
* `cliOverrides` and `envOverrides` are partial — only the keys the
|
|
197
|
+
* caller actually wants to override should be set. `undefined` keys
|
|
198
|
+
* are skipped, so the existing flag parsers in cli.ts can pass through
|
|
199
|
+
* their normalized output without filtering nulls.
|
|
200
|
+
*/
|
|
201
|
+
export function resolveConfig(opts) {
|
|
202
|
+
const fromFile = loadConfig(opts.path);
|
|
203
|
+
const withEnv = mergeOver(fromFile.config, opts.envOverrides ?? {});
|
|
204
|
+
const withCli = mergeOver(withEnv, opts.cliOverrides ?? {});
|
|
205
|
+
return { ...fromFile, config: withCli };
|
|
206
|
+
}
|
|
207
|
+
// ── internals ────────────────────────────────────────────────────────
|
|
208
|
+
function isPlainObject(v) {
|
|
209
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Field-by-field type-check. Drops keys whose values don't match the
|
|
213
|
+
* expected type — better to silently fall back to a default than to
|
|
214
|
+
* abort startup on a stray manually-edited typo. Unknown top-level
|
|
215
|
+
* keys pass through unchanged (forward-compat for future fields).
|
|
216
|
+
*/
|
|
217
|
+
function sanitize(parsed) {
|
|
218
|
+
const out = { version: CONFIG_SCHEMA_VERSION };
|
|
219
|
+
const pickNumber = (k) => typeof parsed[k] === 'number' && Number.isFinite(parsed[k]) ? parsed[k] : undefined;
|
|
220
|
+
const pickBool = (k) => typeof parsed[k] === 'boolean' ? parsed[k] : undefined;
|
|
221
|
+
const pickString = (k) => typeof parsed[k] === 'string' ? parsed[k] : undefined;
|
|
222
|
+
const pickStringOrNull = (k) => {
|
|
223
|
+
if (parsed[k] === null)
|
|
224
|
+
return null;
|
|
225
|
+
if (typeof parsed[k] === 'string')
|
|
226
|
+
return parsed[k];
|
|
227
|
+
return undefined;
|
|
228
|
+
};
|
|
229
|
+
const pickNumberOrNull = (k) => {
|
|
230
|
+
if (parsed[k] === null)
|
|
231
|
+
return null;
|
|
232
|
+
if (typeof parsed[k] === 'number' && Number.isFinite(parsed[k]))
|
|
233
|
+
return parsed[k];
|
|
234
|
+
return undefined;
|
|
235
|
+
};
|
|
236
|
+
if (typeof parsed.version === 'number')
|
|
237
|
+
out.version = parsed.version;
|
|
238
|
+
if (pickNumber('port') !== undefined)
|
|
239
|
+
out.port = pickNumber('port');
|
|
240
|
+
if (pickString('host') !== undefined)
|
|
241
|
+
out.host = pickString('host');
|
|
242
|
+
const model = pickStringOrNull('model');
|
|
243
|
+
if (model !== undefined)
|
|
244
|
+
out.model = model;
|
|
245
|
+
for (const k of ['passthrough', 'preserveTools', 'hybridTools', 'mergeTools',
|
|
246
|
+
'noAutoDetect', 'strictTls', 'strictTemplate', 'noLiveCapture',
|
|
247
|
+
'drainOnClose', 'stealth', 'preserveOrchestrationTags']) {
|
|
248
|
+
const v = pickBool(k);
|
|
249
|
+
// Each `k` is a literal boolean-typed field on DarioConfig (verified
|
|
250
|
+
// by the `as const` tuple type above), so the assignment is sound
|
|
251
|
+
// — we route through `unknown` because TS can't narrow the union
|
|
252
|
+
// of literal keys to a single typed assignment at compile time.
|
|
253
|
+
if (v !== undefined)
|
|
254
|
+
out[k] = v;
|
|
255
|
+
}
|
|
256
|
+
// Nested groups — sanitize each, drop if not an object
|
|
257
|
+
if (isPlainObject(parsed.pacing)) {
|
|
258
|
+
out.pacing = {};
|
|
259
|
+
if (typeof parsed.pacing.minMs === 'number')
|
|
260
|
+
out.pacing.minMs = parsed.pacing.minMs;
|
|
261
|
+
if (typeof parsed.pacing.jitterMs === 'number')
|
|
262
|
+
out.pacing.jitterMs = parsed.pacing.jitterMs;
|
|
263
|
+
}
|
|
264
|
+
if (isPlainObject(parsed.thinkTime)) {
|
|
265
|
+
out.thinkTime = {};
|
|
266
|
+
for (const k of ['baseMs', 'perTokenMs', 'jitterMs', 'maxMs']) {
|
|
267
|
+
if (typeof parsed.thinkTime[k] === 'number') {
|
|
268
|
+
out.thinkTime[k] = parsed.thinkTime[k];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (isPlainObject(parsed.sessionStart)) {
|
|
273
|
+
out.sessionStart = {};
|
|
274
|
+
if (typeof parsed.sessionStart.minMs === 'number')
|
|
275
|
+
out.sessionStart.minMs = parsed.sessionStart.minMs;
|
|
276
|
+
if (typeof parsed.sessionStart.jitterMs === 'number')
|
|
277
|
+
out.sessionStart.jitterMs = parsed.sessionStart.jitterMs;
|
|
278
|
+
}
|
|
279
|
+
if (isPlainObject(parsed.session)) {
|
|
280
|
+
out.session = {};
|
|
281
|
+
if (typeof parsed.session.idleRotateMs === 'number')
|
|
282
|
+
out.session.idleRotateMs = parsed.session.idleRotateMs;
|
|
283
|
+
if (typeof parsed.session.rotateJitterMs === 'number')
|
|
284
|
+
out.session.rotateJitterMs = parsed.session.rotateJitterMs;
|
|
285
|
+
if (parsed.session.maxAgeMs === null || typeof parsed.session.maxAgeMs === 'number') {
|
|
286
|
+
out.session.maxAgeMs = parsed.session.maxAgeMs;
|
|
287
|
+
}
|
|
288
|
+
if (typeof parsed.session.perClient === 'boolean')
|
|
289
|
+
out.session.perClient = parsed.session.perClient;
|
|
290
|
+
}
|
|
291
|
+
if (isPlainObject(parsed.queue)) {
|
|
292
|
+
out.queue = {};
|
|
293
|
+
for (const k of ['maxConcurrent', 'maxQueued', 'timeoutMs']) {
|
|
294
|
+
const v = parsed.queue[k];
|
|
295
|
+
if (v === null || (typeof v === 'number' && Number.isFinite(v))) {
|
|
296
|
+
out.queue[k] = v;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const effort = pickStringOrNull('effort');
|
|
301
|
+
if (effort !== undefined)
|
|
302
|
+
out.effort = effort;
|
|
303
|
+
// maxTokens is special — it's a number, the string 'client', or null
|
|
304
|
+
if (parsed.maxTokens === null)
|
|
305
|
+
out.maxTokens = null;
|
|
306
|
+
else if (parsed.maxTokens === 'client')
|
|
307
|
+
out.maxTokens = 'client';
|
|
308
|
+
else {
|
|
309
|
+
const n = pickNumber('maxTokens');
|
|
310
|
+
if (n !== undefined)
|
|
311
|
+
out.maxTokens = n;
|
|
312
|
+
}
|
|
313
|
+
if (Array.isArray(parsed.passthroughBetas)) {
|
|
314
|
+
out.passthroughBetas = parsed.passthroughBetas
|
|
315
|
+
.filter((x) => typeof x === 'string');
|
|
316
|
+
}
|
|
317
|
+
const sysPrompt = pickStringOrNull('systemPrompt');
|
|
318
|
+
if (sysPrompt !== undefined)
|
|
319
|
+
out.systemPrompt = sysPrompt;
|
|
320
|
+
const logFile = pickStringOrNull('logFile');
|
|
321
|
+
if (logFile !== undefined)
|
|
322
|
+
out.logFile = logFile;
|
|
323
|
+
// Silence unused-warning helper.
|
|
324
|
+
void pickNumberOrNull;
|
|
325
|
+
return out;
|
|
326
|
+
}
|
package/dist/proxy.js
CHANGED
|
@@ -567,7 +567,12 @@ export async function startProxy(opts = {}) {
|
|
|
567
567
|
// eventual `7d_opus`) doesn't slip past unnoticed. Pure observability —
|
|
568
568
|
// routing already handles unknown families generically.
|
|
569
569
|
const seenPerModelBuckets = new Set();
|
|
570
|
-
|
|
570
|
+
// v4 promotion: analytics is always-on so the TUI's Analytics + Hits
|
|
571
|
+
// tabs work in both pool and single-account mode. Pre-v4 this was
|
|
572
|
+
// `pool ? new Analytics() : null` — that gated the /analytics
|
|
573
|
+
// endpoint, but burn-rate / per-request visibility is useful for
|
|
574
|
+
// single-account users too.
|
|
575
|
+
const analytics = new Analytics();
|
|
571
576
|
let status;
|
|
572
577
|
if (pool) {
|
|
573
578
|
for (const acc of accountsList) {
|
|
@@ -912,17 +917,70 @@ export async function startProxy(opts = {}) {
|
|
|
912
917
|
}));
|
|
913
918
|
return;
|
|
914
919
|
}
|
|
915
|
-
// Analytics endpoint —
|
|
920
|
+
// Analytics endpoint — rolling-window summary + burn-rate snapshot.
|
|
921
|
+
// Always-on as of v4 (pre-v4 this was gated to pool mode).
|
|
916
922
|
if (urlPath === '/analytics' && req.method === 'GET') {
|
|
917
|
-
if (!analytics) {
|
|
918
|
-
res.writeHead(200, JSON_HEADERS);
|
|
919
|
-
res.end(JSON.stringify({ mode: 'single-account', note: 'Analytics are only collected in pool mode.' }));
|
|
920
|
-
return;
|
|
921
|
-
}
|
|
922
923
|
res.writeHead(200, JSON_HEADERS);
|
|
923
924
|
res.end(JSON.stringify(analytics.summary()));
|
|
924
925
|
return;
|
|
925
926
|
}
|
|
927
|
+
// Analytics live stream — SSE of new RequestRecord JSON, one event
|
|
928
|
+
// per record as it lands. Drives the v4 TUI's Hits tab. Sends a
|
|
929
|
+
// backlog of the most-recent 50 records on connect so a freshly-
|
|
930
|
+
// attached subscriber sees state immediately, then live-tails.
|
|
931
|
+
//
|
|
932
|
+
// Auth: same as /analytics — no auth in single-account default mode;
|
|
933
|
+
// the proxy listens on loopback by default. DARIO_API_KEY users
|
|
934
|
+
// get rejected by the earlier auth gate up the handler chain.
|
|
935
|
+
//
|
|
936
|
+
// Disconnect handling: the 'close' event on `req` removes our
|
|
937
|
+
// listener from the Analytics EventEmitter so we don't leak.
|
|
938
|
+
if (urlPath === '/analytics/stream' && req.method === 'GET') {
|
|
939
|
+
// SECURITY_HEADERS sets Cache-Control: no-store; SSE wants
|
|
940
|
+
// no-cache, no-transform. Spread SECURITY_HEADERS first then
|
|
941
|
+
// override the cache directive — order matters since spread
|
|
942
|
+
// overlap is last-wins in JS.
|
|
943
|
+
const sseHeaders = {
|
|
944
|
+
...SECURITY_HEADERS,
|
|
945
|
+
'Content-Type': 'text/event-stream',
|
|
946
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
947
|
+
'Connection': 'keep-alive',
|
|
948
|
+
'X-Accel-Buffering': 'no', // disable any proxy buffering
|
|
949
|
+
'Access-Control-Allow-Origin': corsOrigin,
|
|
950
|
+
};
|
|
951
|
+
res.writeHead(200, sseHeaders);
|
|
952
|
+
// Backlog: replay recent records so a TUI attaching mid-session
|
|
953
|
+
// sees something. 50 is a soft default; lots of room to send more
|
|
954
|
+
// since this is one-time on connect.
|
|
955
|
+
for (const past of analytics.recent(50)) {
|
|
956
|
+
res.write(`data: ${JSON.stringify(past)}\n\n`);
|
|
957
|
+
}
|
|
958
|
+
// Live tail
|
|
959
|
+
const onRecord = (r) => {
|
|
960
|
+
// Use try/catch so a broken socket (peer hung up between events)
|
|
961
|
+
// doesn't crash the request hot-path — Analytics already wraps
|
|
962
|
+
// its emit in try/catch but the .write itself can also throw.
|
|
963
|
+
try {
|
|
964
|
+
res.write(`data: ${JSON.stringify(r)}\n\n`);
|
|
965
|
+
}
|
|
966
|
+
catch { /* ignored */ }
|
|
967
|
+
};
|
|
968
|
+
analytics.on('record', onRecord);
|
|
969
|
+
// Heartbeat every 25s — SSE comments are ignored by clients but
|
|
970
|
+
// keep middle-boxes (CDNs, dev-proxies) from closing the pipe.
|
|
971
|
+
const heartbeat = setInterval(() => {
|
|
972
|
+
try {
|
|
973
|
+
res.write(`: heartbeat ${Date.now()}\n\n`);
|
|
974
|
+
}
|
|
975
|
+
catch { /* ignored */ }
|
|
976
|
+
}, 25_000);
|
|
977
|
+
heartbeat.unref?.();
|
|
978
|
+
req.on('close', () => {
|
|
979
|
+
analytics.off('record', onRecord);
|
|
980
|
+
clearInterval(heartbeat);
|
|
981
|
+
});
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
926
984
|
if (urlPath === '/v1/models' && req.method === 'GET') {
|
|
927
985
|
requestCount++;
|
|
928
986
|
res.writeHead(200, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
|
|
@@ -1585,12 +1643,19 @@ export async function startProxy(opts = {}) {
|
|
|
1585
1643
|
}
|
|
1586
1644
|
}
|
|
1587
1645
|
requestCount++;
|
|
1588
|
-
|
|
1646
|
+
// v4: analytics is always-on. Pool mode supplies the rate-limit
|
|
1647
|
+
// snapshot from `poolAccount.rateLimit` (already authoritative);
|
|
1648
|
+
// single-account mode parses it from the upstream response
|
|
1649
|
+
// headers on the spot so the TUI's Hits feed shows the same
|
|
1650
|
+
// bucket / utilization fields in both modes.
|
|
1651
|
+
{
|
|
1652
|
+
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
1589
1653
|
analytics.record({
|
|
1590
|
-
timestamp: Date.now(),
|
|
1654
|
+
timestamp: Date.now(),
|
|
1655
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
|
|
1656
|
+
model: requestModel,
|
|
1591
1657
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
1592
|
-
claim:
|
|
1593
|
-
util7d: poolAccount.rateLimit.util7d, overageUtil: poolAccount.rateLimit.overageUtil,
|
|
1658
|
+
claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
1594
1659
|
latencyMs: Date.now() - startTime, status: 429, isStream: false, isOpenAI,
|
|
1595
1660
|
});
|
|
1596
1661
|
}
|
|
@@ -1669,12 +1734,14 @@ export async function startProxy(opts = {}) {
|
|
|
1669
1734
|
}
|
|
1670
1735
|
}
|
|
1671
1736
|
requestCount++;
|
|
1672
|
-
|
|
1737
|
+
{
|
|
1738
|
+
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
1673
1739
|
analytics.record({
|
|
1674
|
-
timestamp: Date.now(),
|
|
1740
|
+
timestamp: Date.now(),
|
|
1741
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
|
|
1742
|
+
model: requestModel,
|
|
1675
1743
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
1676
|
-
claim:
|
|
1677
|
-
util7d: poolAccount.rateLimit.util7d, overageUtil: poolAccount.rateLimit.overageUtil,
|
|
1744
|
+
claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
1678
1745
|
latencyMs: Date.now() - startTime, status: 429, isStream: false, isOpenAI,
|
|
1679
1746
|
});
|
|
1680
1747
|
}
|
|
@@ -1880,14 +1947,16 @@ export async function startProxy(opts = {}) {
|
|
|
1880
1947
|
lastResponseTime = Date.now();
|
|
1881
1948
|
lastResponseTokens = streamOutputTokens;
|
|
1882
1949
|
}
|
|
1883
|
-
|
|
1950
|
+
{
|
|
1951
|
+
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
1884
1952
|
analytics.record({
|
|
1885
|
-
timestamp: Date.now(),
|
|
1953
|
+
timestamp: Date.now(),
|
|
1954
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
|
|
1955
|
+
model: requestModel,
|
|
1886
1956
|
inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
|
|
1887
1957
|
cacheReadTokens: streamCacheReadTokens, cacheCreateTokens: streamCacheCreateTokens,
|
|
1888
1958
|
thinkingTokens: 0,
|
|
1889
|
-
claim:
|
|
1890
|
-
util7d: poolAccount.rateLimit.util7d, overageUtil: poolAccount.rateLimit.overageUtil,
|
|
1959
|
+
claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
1891
1960
|
latencyMs: Date.now() - startTime, status: upstream.status, isStream: true, isOpenAI,
|
|
1892
1961
|
});
|
|
1893
1962
|
}
|
|
@@ -1938,16 +2007,17 @@ export async function startProxy(opts = {}) {
|
|
|
1938
2007
|
lastResponseTime = Date.now();
|
|
1939
2008
|
lastResponseTokens = bufferedUsage?.outputTokens ?? 0;
|
|
1940
2009
|
}
|
|
1941
|
-
if (
|
|
2010
|
+
if (bufferedUsage) {
|
|
1942
2011
|
try {
|
|
2012
|
+
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
1943
2013
|
analytics.record({
|
|
1944
|
-
timestamp: Date.now(),
|
|
2014
|
+
timestamp: Date.now(),
|
|
2015
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
|
|
1945
2016
|
model: bufferedUsage.model || requestModel,
|
|
1946
2017
|
inputTokens: bufferedUsage.inputTokens, outputTokens: bufferedUsage.outputTokens,
|
|
1947
2018
|
cacheReadTokens: bufferedUsage.cacheReadTokens, cacheCreateTokens: bufferedUsage.cacheCreateTokens,
|
|
1948
2019
|
thinkingTokens: bufferedUsage.thinkingTokens,
|
|
1949
|
-
claim:
|
|
1950
|
-
util7d: poolAccount.rateLimit.util7d, overageUtil: poolAccount.rateLimit.overageUtil,
|
|
2020
|
+
claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
1951
2021
|
latencyMs: Date.now() - startTime, status: upstream.status, isStream: false, isOpenAI,
|
|
1952
2022
|
});
|
|
1953
2023
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TUI App — main render loop + lifecycle + key dispatch.
|
|
3
|
+
*
|
|
4
|
+
* The shape is deliberately simple: one render function (caller-
|
|
5
|
+
* supplied) that takes `state + dimensions` and returns the full frame
|
|
6
|
+
* as a string. The App drives the loop, manages stdin raw mode, handles
|
|
7
|
+
* resize events, and flushes one write per frame. Tabs (M4) are
|
|
8
|
+
* decoupled state machines that the caller's render() composes.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle invariants:
|
|
11
|
+
*
|
|
12
|
+
* - Enters alt-screen on start, leaves on stop. The shell that
|
|
13
|
+
* spawned dario sees its prior content restored when the TUI quits.
|
|
14
|
+
* - Hides the cursor on start, restores it on stop. ALWAYS, even on
|
|
15
|
+
* uncaught exceptions or SIGINT — we install signal + exit hooks.
|
|
16
|
+
* - Sets stdin raw mode on start, restores it on stop.
|
|
17
|
+
*
|
|
18
|
+
* The hook chain is registered on process events; quit calls them all
|
|
19
|
+
* synchronously so no terminal state leaks out.
|
|
20
|
+
*/
|
|
21
|
+
import { type Key } from './input.js';
|
|
22
|
+
export interface AppOptions<S> {
|
|
23
|
+
/**
|
|
24
|
+
* Initial state. The App holds a reference and re-renders when
|
|
25
|
+
* `setState` is called.
|
|
26
|
+
*/
|
|
27
|
+
initialState: S;
|
|
28
|
+
/**
|
|
29
|
+
* Pure function: state + dimensions → full screen content.
|
|
30
|
+
* The App calls this on every redraw + flushes the returned string
|
|
31
|
+
* to stdout in a single write.
|
|
32
|
+
*/
|
|
33
|
+
render: (state: S, dim: {
|
|
34
|
+
cols: number;
|
|
35
|
+
rows: number;
|
|
36
|
+
}) => string;
|
|
37
|
+
/**
|
|
38
|
+
* Key dispatch. Receives every parsed key from stdin (after the
|
|
39
|
+
* App has already handled global keys like Ctrl-C). Return a new
|
|
40
|
+
* state (or undefined for no change) — same shape as React's
|
|
41
|
+
* setState reducer.
|
|
42
|
+
*/
|
|
43
|
+
onKey: (state: S, key: Key) => S | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Optional: called on every redraw after the new frame has been
|
|
46
|
+
* written. Used by tabs that have async data (e.g. SSE-fed Hits
|
|
47
|
+
* tab) to schedule periodic refreshes.
|
|
48
|
+
*/
|
|
49
|
+
afterFrame?: (state: S) => void;
|
|
50
|
+
/**
|
|
51
|
+
* stdin / stdout — overridable for tests. Defaults to process.stdin
|
|
52
|
+
* and process.stdout.
|
|
53
|
+
*/
|
|
54
|
+
stdin?: NodeJS.ReadStream;
|
|
55
|
+
stdout?: NodeJS.WriteStream;
|
|
56
|
+
}
|
|
57
|
+
export declare class App<S> {
|
|
58
|
+
private state;
|
|
59
|
+
private renderFn;
|
|
60
|
+
private keyFn;
|
|
61
|
+
private afterFrameFn?;
|
|
62
|
+
private stdin;
|
|
63
|
+
private stdout;
|
|
64
|
+
private cleanupFns;
|
|
65
|
+
private running;
|
|
66
|
+
private redrawScheduled;
|
|
67
|
+
constructor(opts: AppOptions<S>);
|
|
68
|
+
/**
|
|
69
|
+
* Replace state. If the new state differs from the old (shallow
|
|
70
|
+
* equality), schedule a redraw. Tabs use this both for synchronous
|
|
71
|
+
* key-driven updates and for async data arrivals (SSE 'record').
|
|
72
|
+
*
|
|
73
|
+
* The "differs" check is intentionally shallow — deep equality on
|
|
74
|
+
* potentially-large analytics records would be expensive and the
|
|
75
|
+
* caller almost always passes new object identity when it mutates.
|
|
76
|
+
* If you mutate state in place and don't change identity, the
|
|
77
|
+
* redraw won't fire (this is documented; sometimes desired).
|
|
78
|
+
*/
|
|
79
|
+
setState(updater: Partial<S> | ((s: S) => S)): void;
|
|
80
|
+
/** Read-only state accessor — for callers that need to compute next state from current. */
|
|
81
|
+
getState(): S;
|
|
82
|
+
/**
|
|
83
|
+
* Start the TUI: enter alt-screen, hide cursor, raw stdin, attach
|
|
84
|
+
* resize listener, render once, then idle until stop() or process
|
|
85
|
+
* exit.
|
|
86
|
+
*
|
|
87
|
+
* Returns a Promise that resolves when stop() is called. Wires
|
|
88
|
+
* process exit / signal hooks so a Ctrl-C / kill leaves the
|
|
89
|
+
* terminal sane.
|
|
90
|
+
*/
|
|
91
|
+
start(): Promise<void>;
|
|
92
|
+
/** Stop the TUI — restore terminal state and resolve the start() promise. */
|
|
93
|
+
stop(): void;
|
|
94
|
+
private scheduleRedraw;
|
|
95
|
+
private redraw;
|
|
96
|
+
}
|