@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.
@@ -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
- const analytics = pool ? new Analytics() : null;
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 — request history + burn-rate summary (pool mode only).
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
- if (analytics && poolAccount) {
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(), account: poolAccount.alias, model: requestModel,
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: poolAccount.rateLimit.claim, util5h: poolAccount.rateLimit.util5h,
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
- if (analytics && poolAccount) {
1737
+ {
1738
+ const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
1673
1739
  analytics.record({
1674
- timestamp: Date.now(), account: poolAccount.alias, model: requestModel,
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: poolAccount.rateLimit.claim, util5h: poolAccount.rateLimit.util5h,
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
- if (analytics && poolAccount) {
1950
+ {
1951
+ const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
1884
1952
  analytics.record({
1885
- timestamp: Date.now(), account: poolAccount.alias, model: requestModel,
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: poolAccount.rateLimit.claim, util5h: poolAccount.rateLimit.util5h,
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 (analytics && poolAccount && bufferedUsage) {
2010
+ if (bufferedUsage) {
1942
2011
  try {
2012
+ const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
1943
2013
  analytics.record({
1944
- timestamp: Date.now(), account: poolAccount.alias,
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: poolAccount.rateLimit.claim, util5h: poolAccount.rateLimit.util5h,
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
+ }