@askalf/dario 6.4.0 → 6.6.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/ledger.js ADDED
@@ -0,0 +1,411 @@
1
+ /**
2
+ * The ledger — what the traffic dario has served would have cost on the
3
+ * metered API, kept across restarts.
4
+ *
5
+ * /analytics is a rolling in-memory window: it forgets on every restart and
6
+ * caps at 10k records, so the one number a subscription user actually wants
7
+ * — "what has this saved me" — was never answerable past the last few hours.
8
+ * The ledger keeps one small row per (UTC day, model, bucket): a request
9
+ * count and the four token buckets. It never stores a price. Rows are priced
10
+ * at read time through `costOfTokens` at the day's own timestamp, so a
11
+ * pricing correction (#1047, #1048 — both happened) reprices history instead
12
+ * of freezing the wrong number in.
13
+ *
14
+ * Two buckets per row. `covered` is traffic a subscription paid for — the
15
+ * API-equivalent cost of that is the headline, the invoice that never
16
+ * arrived. `metered` is traffic billed per token anyway (an API key, or
17
+ * Anthropic's paid `extra_usage` overage) — that money was spent, and it is
18
+ * reported separately rather than counted as saved. Only 2xx responses
19
+ * count: a 429 carries no tokens and a 5xx bills nothing.
20
+ *
21
+ * On disk: `~/.dario/ledger.json` for the default port, `ledger-<port>.json`
22
+ * for any other, so two instances sharing a home (the box's live-test rig
23
+ * runs one on :3999 next to production) do not overwrite each other's file.
24
+ * Writes are debounced and durable (`durableWriteFile`); the shutdown hook
25
+ * flushes what the debounce still holds, so at most the last few seconds
26
+ * before a SIGKILL are lost.
27
+ */
28
+ import { readFile, mkdir, rename } from 'node:fs/promises';
29
+ import { dirname, join } from 'node:path';
30
+ import { homedir } from 'node:os';
31
+ import { durableWriteFile } from './durable-write.js';
32
+ import { billingBucketFromClaim, costOfTokens, providerOfModel } from './analytics.js';
33
+ export const LEDGER_VERSION = 1;
34
+ /** The default proxy port; any other port gets its own ledger file. */
35
+ const DEFAULT_PORT = 3456;
36
+ /** Days kept before the oldest roll off — two years at one row per model per day. */
37
+ export const LEDGER_MAX_DAYS = 730;
38
+ /** How long after the last record the file is rewritten. */
39
+ export const LEDGER_FLUSH_DELAY_MS = 3_000;
40
+ export function ledgerPathFor(port, home = homedir()) {
41
+ return join(home, '.dario', port === DEFAULT_PORT ? 'ledger.json' : `ledger-${port}.json`);
42
+ }
43
+ /**
44
+ * `DARIO_LEDGER_PATH` names the file; `DARIO_LEDGER=0` (or `--no-ledger`)
45
+ * turns the ledger off. Off, /analytics reports `lifetime: null` and the
46
+ * usage command says so.
47
+ */
48
+ export function resolveLedgerPath(port, env = process.env) {
49
+ const explicit = env['DARIO_LEDGER_PATH'];
50
+ return explicit && explicit.trim().length > 0 ? explicit.trim() : ledgerPathFor(port);
51
+ }
52
+ export function ledgerDisabledByEnv(env = process.env) {
53
+ return ['0', 'false', 'no', 'off'].includes((env['DARIO_LEDGER'] ?? '').toLowerCase());
54
+ }
55
+ const emptyCell = () => ({ requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 });
56
+ export function emptyLedger(now = Date.now()) {
57
+ const iso = new Date(now).toISOString();
58
+ return { version: LEDGER_VERSION, since: iso, updated: iso, days: {} };
59
+ }
60
+ /** UTC calendar day of an epoch-ms timestamp. */
61
+ export function dayKey(atMs) {
62
+ return new Date(atMs).toISOString().slice(0, 10);
63
+ }
64
+ /** Noon UTC of a day key — inside any intro window that ends that day. */
65
+ function dayMs(day) {
66
+ return Date.parse(`${day}T12:00:00Z`);
67
+ }
68
+ /**
69
+ * Which bucket a record lands in, or null when it should not be counted.
70
+ * `api` and `extra_usage` are metered; every subscription claim, the codex
71
+ * claim, and an absent claim on a 2xx (stream aborts, api-key mode without
72
+ * the header) are covered — the request was served, and nothing says it was
73
+ * billed per token.
74
+ */
75
+ export function ledgerBucketFor(record) {
76
+ if (record.status < 200 || record.status >= 300)
77
+ return null;
78
+ const bucket = billingBucketFromClaim(record.claim);
79
+ return bucket === 'api' || bucket === 'extra_usage' ? 'metered' : 'covered';
80
+ }
81
+ function isCell(v) {
82
+ if (!v || typeof v !== 'object')
83
+ return false;
84
+ const c = v;
85
+ return ['requests', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreateTokens']
86
+ .every((k) => typeof c[k] === 'number' && Number.isFinite(c[k]) && c[k] >= 0);
87
+ }
88
+ /**
89
+ * Parse a ledger file's text, keeping only well-formed rows. A file that is
90
+ * not a ledger at all throws; the caller moves it aside and starts fresh.
91
+ */
92
+ export function parseLedger(text) {
93
+ const raw = JSON.parse(text);
94
+ if (!raw || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || !raw.days || typeof raw.days !== 'object') {
95
+ throw new Error('not a dario ledger');
96
+ }
97
+ const days = {};
98
+ for (const [day, models] of Object.entries(raw.days)) {
99
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !models || typeof models !== 'object')
100
+ continue;
101
+ const clean = {};
102
+ for (const [model, row] of Object.entries(models)) {
103
+ if (!row || typeof row !== 'object')
104
+ continue;
105
+ const r = {};
106
+ if (isCell(row.covered))
107
+ r.covered = { ...row.covered };
108
+ if (isCell(row.metered))
109
+ r.metered = { ...row.metered };
110
+ if (r.covered || r.metered)
111
+ clean[model] = r;
112
+ }
113
+ if (Object.keys(clean).length > 0)
114
+ days[day] = clean;
115
+ }
116
+ const since = typeof raw.since === 'string' && !Number.isNaN(Date.parse(raw.since)) ? raw.since : new Date().toISOString();
117
+ const updated = typeof raw.updated === 'string' && !Number.isNaN(Date.parse(raw.updated)) ? raw.updated : since;
118
+ return { version: LEDGER_VERSION, since, updated, days };
119
+ }
120
+ /** Add one record's tokens to the file in place. Returns false when it was not counted. */
121
+ export function addToLedger(file, record) {
122
+ const bucket = ledgerBucketFor(record);
123
+ if (!bucket)
124
+ return false;
125
+ const day = dayKey(record.timestamp);
126
+ const model = record.model || 'unknown';
127
+ const models = (file.days[day] ??= {});
128
+ const row = (models[model] ??= {});
129
+ const cell = (row[bucket] ??= emptyCell());
130
+ cell.requests += 1;
131
+ cell.inputTokens += record.inputTokens;
132
+ cell.outputTokens += record.outputTokens;
133
+ cell.cacheReadTokens += record.cacheReadTokens;
134
+ cell.cacheCreateTokens += record.cacheCreateTokens;
135
+ if (Date.parse(file.since) > record.timestamp)
136
+ file.since = new Date(record.timestamp).toISOString();
137
+ pruneLedger(file);
138
+ return true;
139
+ }
140
+ /** Drop the oldest days past LEDGER_MAX_DAYS. */
141
+ export function pruneLedger(file, maxDays = LEDGER_MAX_DAYS) {
142
+ const days = Object.keys(file.days).sort();
143
+ for (const day of days.slice(0, Math.max(0, days.length - maxDays)))
144
+ delete file.days[day];
145
+ }
146
+ // Six places, not the window's four: a handful of gpt-5.6-luna requests is
147
+ // real money in the millionths and "$0 for 2 requests" reads as free.
148
+ const round = (usd) => Math.round(usd * 1_000_000) / 1_000_000;
149
+ export function summarizeLedger(file, path, now = Date.now()) {
150
+ const today = dayKey(now);
151
+ const cutoff7 = dayKey(now - 6 * 86_400_000);
152
+ const cutoff30 = dayKey(now - 29 * 86_400_000);
153
+ const perModel = {};
154
+ const perProvider = {
155
+ anthropic: { requests: 0, apiEquivalentCost: 0 },
156
+ openai: { requests: 0, apiEquivalentCost: 0 },
157
+ };
158
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
159
+ let requests = 0;
160
+ let covered = 0;
161
+ let metered = 0;
162
+ const recent = { today: 0, last7d: 0, last30d: 0 };
163
+ for (const [day, models] of Object.entries(file.days)) {
164
+ const at = dayMs(day);
165
+ for (const [model, row] of Object.entries(models)) {
166
+ const m = (perModel[model] ??= {
167
+ provider: providerOfModel(model), requests: 0,
168
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0,
169
+ apiEquivalentCost: 0, meteredCost: 0,
170
+ });
171
+ if (row.covered) {
172
+ const cost = costOfTokens(model, at, row.covered);
173
+ covered += cost;
174
+ m.apiEquivalentCost += cost;
175
+ m.requests += row.covered.requests;
176
+ m.inputTokens += row.covered.inputTokens;
177
+ m.outputTokens += row.covered.outputTokens;
178
+ m.cacheReadTokens += row.covered.cacheReadTokens;
179
+ m.cacheCreateTokens += row.covered.cacheCreateTokens;
180
+ tokens.input += row.covered.inputTokens;
181
+ tokens.output += row.covered.outputTokens;
182
+ tokens.cacheRead += row.covered.cacheReadTokens;
183
+ tokens.cacheCreate += row.covered.cacheCreateTokens;
184
+ perProvider[m.provider].requests += row.covered.requests;
185
+ perProvider[m.provider].apiEquivalentCost += cost;
186
+ requests += row.covered.requests;
187
+ if (day === today)
188
+ recent.today += cost;
189
+ if (day >= cutoff7)
190
+ recent.last7d += cost;
191
+ if (day >= cutoff30)
192
+ recent.last30d += cost;
193
+ }
194
+ if (row.metered) {
195
+ const cost = costOfTokens(model, at, row.metered);
196
+ metered += cost;
197
+ m.meteredCost += cost;
198
+ m.requests += row.metered.requests;
199
+ requests += row.metered.requests;
200
+ }
201
+ }
202
+ }
203
+ for (const m of Object.values(perModel)) {
204
+ m.apiEquivalentCost = round(m.apiEquivalentCost);
205
+ m.meteredCost = round(m.meteredCost);
206
+ }
207
+ for (const p of Object.values(perProvider))
208
+ p.apiEquivalentCost = round(p.apiEquivalentCost);
209
+ return {
210
+ path,
211
+ since: file.since,
212
+ days: Object.keys(file.days).length,
213
+ requests,
214
+ apiEquivalentCost: round(covered),
215
+ meteredCost: round(metered),
216
+ tokens,
217
+ perProvider,
218
+ perModel,
219
+ recent: { today: round(recent.today), last7d: round(recent.last7d), last30d: round(recent.last30d) },
220
+ };
221
+ }
222
+ /**
223
+ * Read a ledger file for display without a running proxy (`dario usage`
224
+ * when the proxy is down). Missing file → null; unreadable → null with the
225
+ * reason, never a throw.
226
+ */
227
+ export async function readLedgerFile(path) {
228
+ let text;
229
+ try {
230
+ text = await readFile(path, 'utf8');
231
+ }
232
+ catch (err) {
233
+ const code = err.code;
234
+ return code === 'ENOENT' ? { file: null } : { file: null, error: err.message };
235
+ }
236
+ try {
237
+ return { file: parseLedger(text) };
238
+ }
239
+ catch (err) {
240
+ return { file: null, error: err.message };
241
+ }
242
+ }
243
+ export class Ledger {
244
+ path;
245
+ log;
246
+ file;
247
+ dirty = false;
248
+ timer = null;
249
+ writing = Promise.resolve();
250
+ closed = false;
251
+ constructor(path, file, log) {
252
+ this.path = path;
253
+ this.log = log;
254
+ this.file = file;
255
+ }
256
+ /**
257
+ * Load the ledger at `path`, or start one. A file that cannot be parsed is
258
+ * moved aside (`<path>.corrupt-<ts>`) rather than overwritten, so a bad
259
+ * write never silently zeroes two years of history.
260
+ */
261
+ static async open(path, log = () => { }) {
262
+ const { file, error } = await readLedgerFile(path);
263
+ if (file)
264
+ return new Ledger(path, file, log);
265
+ if (error) {
266
+ const aside = `${path}.corrupt-${Date.now()}`;
267
+ try {
268
+ await rename(path, aside);
269
+ }
270
+ catch { /* best effort — the next flush overwrites */ }
271
+ log(`[dario] ledger: could not read ${path} (${error}); moved aside to ${aside}, starting fresh`);
272
+ }
273
+ return new Ledger(path, emptyLedger(), log);
274
+ }
275
+ /** Count a request. Returns false when it was not ledger material. */
276
+ add(record) {
277
+ if (this.closed)
278
+ return false;
279
+ const counted = addToLedger(this.file, record);
280
+ if (counted)
281
+ this.scheduleFlush();
282
+ return counted;
283
+ }
284
+ summary(now = Date.now()) {
285
+ return summarizeLedger(this.file, this.path, now);
286
+ }
287
+ /** The raw per-day table, for /analytics/ledger. */
288
+ snapshot() {
289
+ return JSON.parse(JSON.stringify(this.file));
290
+ }
291
+ scheduleFlush() {
292
+ this.dirty = true;
293
+ if (this.timer)
294
+ return;
295
+ this.timer = setTimeout(() => { this.timer = null; void this.flush(); }, LEDGER_FLUSH_DELAY_MS);
296
+ this.timer.unref();
297
+ }
298
+ /** Write now if anything changed. Serialized; a failure is logged, not thrown. */
299
+ flush() {
300
+ if (!this.dirty)
301
+ return this.writing;
302
+ this.dirty = false;
303
+ if (this.timer) {
304
+ clearTimeout(this.timer);
305
+ this.timer = null;
306
+ }
307
+ this.file.updated = new Date().toISOString();
308
+ const text = JSON.stringify(this.file);
309
+ this.writing = this.writing.then(async () => {
310
+ try {
311
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
312
+ await durableWriteFile(this.path, text, 0o600);
313
+ }
314
+ catch (err) {
315
+ this.dirty = true;
316
+ this.log(`[dario] ledger: write to ${this.path} failed: ${err.message}`);
317
+ }
318
+ });
319
+ return this.writing;
320
+ }
321
+ /** Final flush for the shutdown hook. */
322
+ async close() {
323
+ await this.flush();
324
+ this.closed = true;
325
+ }
326
+ }
327
+ // ─────────────────────────────────────────────────────────────────────────
328
+ // Presentation — the number, formatted for a terminal and for a share card
329
+ // ─────────────────────────────────────────────────────────────────────────
330
+ export function formatUsd(usd) {
331
+ if (usd >= 100)
332
+ return `$${Math.round(usd).toLocaleString('en-US')}`;
333
+ if (usd >= 1)
334
+ return `$${usd.toFixed(2)}`;
335
+ if (usd === 0)
336
+ return '$0';
337
+ if (usd < 0.0001)
338
+ return '<$0.0001';
339
+ return `$${usd.toFixed(usd >= 0.01 ? 2 : 4)}`;
340
+ }
341
+ /** `claude-opus-5` → `Opus 5`, `claude-haiku-4-5-20251001` → `Haiku 4.5`, `gpt-5.6-terra` → `gpt-5.6-terra`. */
342
+ export function shortModelName(model) {
343
+ const m = /^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?(\[[^\]]*\])?$/i.exec(model);
344
+ if (!m)
345
+ return model;
346
+ const family = m[1].charAt(0).toUpperCase() + m[1].slice(1);
347
+ return `${family} ${m[2]}${m[3] ? `.${m[3]}` : ''}${m[4] ?? ''}`;
348
+ }
349
+ const providerLabel = { anthropic: 'Claude', openai: 'ChatGPT' };
350
+ /**
351
+ * The block `dario usage` prints above the rolling window. Two-space indent
352
+ * to match the rest of that command's output.
353
+ */
354
+ export function formatLedgerSummary(s) {
355
+ const since = s.since.slice(0, 10);
356
+ const lines = [];
357
+ lines.push(` API-equivalent spend (since ${since}, ${s.days} day${s.days === 1 ? '' : 's'}, ${s.requests.toLocaleString('en-US')} request${s.requests === 1 ? '' : 's'}):`);
358
+ lines.push(` ${formatUsd(s.apiEquivalentCost)} would have been billed on the metered API — covered by subscriptions`);
359
+ const providers = Object.entries(s.perProvider)
360
+ .filter(([, p]) => p.requests > 0)
361
+ .sort((a, b) => b[1].apiEquivalentCost - a[1].apiEquivalentCost);
362
+ for (const [provider, p] of providers) {
363
+ const models = Object.entries(s.perModel)
364
+ .filter(([, m]) => m.provider === provider && m.apiEquivalentCost > 0)
365
+ .sort((a, b) => b[1].apiEquivalentCost - a[1].apiEquivalentCost)
366
+ .slice(0, 4)
367
+ .map(([id, m]) => `${shortModelName(id)} ${formatUsd(m.apiEquivalentCost)}`);
368
+ lines.push(` ${providerLabel[provider].padEnd(8)} ${formatUsd(p.apiEquivalentCost).padStart(9)} ${p.requests.toLocaleString('en-US')} req${p.requests === 1 ? '' : 's'}${models.length > 0 ? ` (${models.join(' · ')})` : ''}`);
369
+ }
370
+ lines.push(` Today ${formatUsd(s.recent.today)} · Last 7d ${formatUsd(s.recent.last7d)} · Last 30d ${formatUsd(s.recent.last30d)}`);
371
+ if (s.meteredCost > 0)
372
+ lines.push(` Paid per token on top (API key / extra usage): ${formatUsd(s.meteredCost)}`);
373
+ return lines;
374
+ }
375
+ const escapeXml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
376
+ /**
377
+ * A share card: one SVG, 640×320, dark, the number in the middle. Plain
378
+ * system monospace so it renders the same in a README, a tweet screenshot
379
+ * and an <img> tag with nothing to fetch.
380
+ */
381
+ export function renderLedgerCard(s) {
382
+ const providers = Object.entries(s.perProvider)
383
+ .filter(([, p]) => p.requests > 0)
384
+ .sort((a, b) => b[1].apiEquivalentCost - a[1].apiEquivalentCost)
385
+ .map(([provider, p]) => `${providerLabel[provider]} ${formatUsd(p.apiEquivalentCost)}`)
386
+ .join(' · ');
387
+ const since = s.since.slice(0, 10);
388
+ const headline = formatUsd(s.apiEquivalentCost);
389
+ const size = headline.length > 9 ? 56 : headline.length > 7 ? 68 : 80;
390
+ const meta = `${s.requests.toLocaleString('en-US')} requests · since ${since} · ${s.days} day${s.days === 1 ? '' : 's'}`;
391
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="320" viewBox="0 0 640 320" role="img" aria-label="${escapeXml(headline)} of API-equivalent usage covered by subscriptions through dario">
392
+ <defs>
393
+ <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
394
+ <stop offset="0" stop-color="#7c3aed"/>
395
+ <stop offset="1" stop-color="#db2777"/>
396
+ </linearGradient>
397
+ <clipPath id="card"><rect width="640" height="320" rx="20"/></clipPath>
398
+ </defs>
399
+ <rect width="640" height="320" rx="20" fill="#0a0a0f"/>
400
+ <rect x="0" y="0" width="640" height="6" fill="url(#accent)" clip-path="url(#card)"/>
401
+ <g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" fill="#e5e7eb">
402
+ <text x="40" y="66" font-size="15" fill="#9ca3af" letter-spacing="2">API-EQUIVALENT SPEND · COVERED BY SUBSCRIPTIONS</text>
403
+ <text x="40" y="160" font-size="${size}" font-weight="700" fill="#ffffff">${escapeXml(headline)}</text>
404
+ <text x="40" y="200" font-size="17" fill="#d1d5db">would have been billed on the metered API</text>
405
+ <text x="40" y="244" font-size="15" fill="#a78bfa">${escapeXml(providers)}</text>
406
+ <text x="40" y="284" font-size="13" fill="#6b7280">${escapeXml(meta)}</text>
407
+ <text x="600" y="284" font-size="13" fill="#6b7280" text-anchor="end">dario</text>
408
+ </g>
409
+ </svg>
410
+ `;
411
+ }
package/dist/proxy.d.ts CHANGED
@@ -326,6 +326,14 @@ interface ProxyOptions {
326
326
  * it off; with no fallback chain it is inert and says so on the first miss.
327
327
  */
328
328
  midstreamContinue?: boolean;
329
+ /**
330
+ * Keep the lifetime ledger (v6.6, src/ledger.ts): per-day, per-model token
331
+ * totals on disk, priced at read time, so /analytics and `dario usage` can
332
+ * say what the traffic would have cost on the metered API since the first
333
+ * request — across restarts. On by default; `--no-ledger` /
334
+ * `DARIO_LEDGER=0` turns it off, `DARIO_LEDGER_PATH` moves the file.
335
+ */
336
+ ledger?: boolean;
329
337
  sessionIdleRotateMs?: number;
330
338
  sessionRotateJitterMs?: number;
331
339
  sessionMaxAgeMs?: number;