@askalf/dario 6.7.1 → 6.8.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 +15 -3
- package/dist/admin-api.d.ts +11 -1
- package/dist/admin-api.js +101 -2
- package/dist/cli.js +213 -3
- package/dist/keys.d.ts +127 -0
- package/dist/keys.js +346 -0
- package/dist/ledger.d.ts +34 -1
- package/dist/ledger.js +122 -9
- package/dist/proxy.d.ts +11 -0
- package/dist/proxy.js +88 -10
- package/docs/admin-api.md +21 -10
- package/docs/api-equivalent-spend.md +16 -1
- package/docs/commands.md +3 -1
- package/docs/configuration.md +7 -0
- package/docs/keys.md +112 -0
- package/docs/multi-account-pool.md +2 -1
- package/package.json +2 -2
package/dist/keys.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named keys — one credential per developer on a shared dario (dario#1318).
|
|
3
|
+
*
|
|
4
|
+
* A team runs one proxy for several people. `DARIO_API_KEY` is one secret for
|
|
5
|
+
* all of them, so nothing says whose traffic is whose except an
|
|
6
|
+
* `x-dario-consumer` header any client can set to anything. A named key ties
|
|
7
|
+
* attribution to the credential: the request authenticated with alice's key
|
|
8
|
+
* IS alice's, in `/analytics`, in the ledger, on every log line.
|
|
9
|
+
*
|
|
10
|
+
* What a key can carry, all optional:
|
|
11
|
+
* seat the pool account this key's traffic prefers. Honoured when that
|
|
12
|
+
* seat is eligible; otherwise the request routes like any other,
|
|
13
|
+
* and failover mid-request is unchanged. A developer's key can
|
|
14
|
+
* ride the developer's own subscription without anyone else's
|
|
15
|
+
* requests landing on it.
|
|
16
|
+
* models an allowlist; a request for any other model is refused (403)
|
|
17
|
+
* before anything goes upstream.
|
|
18
|
+
* expires after which the key is refused like a revoked one.
|
|
19
|
+
*
|
|
20
|
+
* Storage is one file, `~/.dario/keys.json`, mode 0600, holding hashes and
|
|
21
|
+
* never secrets: the secret is printed once at creation and is not
|
|
22
|
+
* recoverable. The running proxy re-reads the file when its mtime moves, so
|
|
23
|
+
* `dario keys create` and the admin API's `/admin/keys` both take effect on
|
|
24
|
+
* the next request with no restart.
|
|
25
|
+
*
|
|
26
|
+
* The wire is untouched. dario already replaces the inbound key with the
|
|
27
|
+
* seat's own bearer before upstream, so a named key changes what dario
|
|
28
|
+
* knows, not what Anthropic sees — passthrough stays byte-identical.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { dirname, join } from 'node:path';
|
|
34
|
+
export const KEYS_VERSION = 1;
|
|
35
|
+
export const KEY_PREFIX = 'dk_';
|
|
36
|
+
/** Same charset as a pool alias — a key name is printed next to one. */
|
|
37
|
+
export const KEY_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_\-.]{0,63}$/;
|
|
38
|
+
export const KEYS_FLUSH_DELAY_MS = 3_000;
|
|
39
|
+
const SECRET_BYTES = 24;
|
|
40
|
+
export function keysPathFor(home = homedir()) {
|
|
41
|
+
return join(home, '.dario', 'keys.json');
|
|
42
|
+
}
|
|
43
|
+
export function resolveKeysPath(env = process.env) {
|
|
44
|
+
const p = env['DARIO_KEYS_PATH'];
|
|
45
|
+
return typeof p === 'string' && p.trim().length > 0 ? p.trim() : keysPathFor();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* sha256, not a slow KDF, on purpose: a key is 24 bytes from `randomBytes`
|
|
49
|
+
* (192 bits of entropy), never a human-chosen password, so a guess is not
|
|
50
|
+
* a threat a KDF could slow down, and this hash runs once per request on the
|
|
51
|
+
* hot path. This is how GitHub and Stripe store their API tokens.
|
|
52
|
+
* (CodeQL's js/insufficient-password-hash fires on the x-api-key source and
|
|
53
|
+
* is dismissed as a false positive for exactly this reason.)
|
|
54
|
+
*/
|
|
55
|
+
export function hashKey(secret) {
|
|
56
|
+
return createHash('sha256').update(secret).digest('hex');
|
|
57
|
+
}
|
|
58
|
+
/** `dk_` + 48 hex characters. The prefix lets a reject log say "a named key" without the value. */
|
|
59
|
+
export function mintSecret() {
|
|
60
|
+
return KEY_PREFIX + randomBytes(SECRET_BYTES).toString('hex');
|
|
61
|
+
}
|
|
62
|
+
export function looksLikeNamedKey(value) {
|
|
63
|
+
return typeof value === 'string' && value.startsWith(KEY_PREFIX);
|
|
64
|
+
}
|
|
65
|
+
export function emptyKeysFile() {
|
|
66
|
+
return { version: KEYS_VERSION, keys: [] };
|
|
67
|
+
}
|
|
68
|
+
const isIso = (v) => typeof v === 'string' && !Number.isNaN(Date.parse(v));
|
|
69
|
+
/**
|
|
70
|
+
* Parse a keys file's text, keeping only well-formed records. A file that is
|
|
71
|
+
* not a keys file at all throws; the caller decides whether to move it aside.
|
|
72
|
+
*/
|
|
73
|
+
export function parseKeysFile(text) {
|
|
74
|
+
const raw = JSON.parse(text);
|
|
75
|
+
if (!raw || typeof raw !== 'object' || raw.version !== KEYS_VERSION || !Array.isArray(raw.keys)) {
|
|
76
|
+
throw new Error('not a dario keys file');
|
|
77
|
+
}
|
|
78
|
+
const keys = [];
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
for (const k of raw.keys) {
|
|
81
|
+
if (!k || typeof k !== 'object')
|
|
82
|
+
continue;
|
|
83
|
+
if (typeof k.name !== 'string' || !KEY_NAME_RE.test(k.name) || seen.has(k.name))
|
|
84
|
+
continue;
|
|
85
|
+
if (typeof k.hash !== 'string' || !/^[0-9a-f]{64}$/.test(k.hash))
|
|
86
|
+
continue;
|
|
87
|
+
if (typeof k.id !== 'string' || !/^[0-9a-f]{8}$/.test(k.id))
|
|
88
|
+
continue;
|
|
89
|
+
const rec = { id: k.id, name: k.name, hash: k.hash, created: isIso(k.created) ? k.created : new Date(0).toISOString() };
|
|
90
|
+
if (isIso(k.lastUsed))
|
|
91
|
+
rec.lastUsed = k.lastUsed;
|
|
92
|
+
if (k.disabled === true)
|
|
93
|
+
rec.disabled = true;
|
|
94
|
+
if (isIso(k.expires))
|
|
95
|
+
rec.expires = k.expires;
|
|
96
|
+
if (typeof k.seat === 'string' && KEY_NAME_RE.test(k.seat))
|
|
97
|
+
rec.seat = k.seat;
|
|
98
|
+
if (Array.isArray(k.models)) {
|
|
99
|
+
const models = k.models.filter((m) => typeof m === 'string' && m.trim().length > 0).map((m) => m.trim());
|
|
100
|
+
if (models.length > 0)
|
|
101
|
+
rec.models = models;
|
|
102
|
+
}
|
|
103
|
+
seen.add(rec.name);
|
|
104
|
+
keys.push(rec);
|
|
105
|
+
}
|
|
106
|
+
return { version: KEYS_VERSION, keys };
|
|
107
|
+
}
|
|
108
|
+
/** Missing file → empty. Unreadable or malformed → throws (never silently empty: that would "revoke" everyone). */
|
|
109
|
+
export function readKeysFile(path) {
|
|
110
|
+
if (!existsSync(path))
|
|
111
|
+
return emptyKeysFile();
|
|
112
|
+
return parseKeysFile(readFileSync(path, 'utf8'));
|
|
113
|
+
}
|
|
114
|
+
/** Atomic, 0600, parent 0700 — the same primitive config.json uses. */
|
|
115
|
+
export function writeKeysFile(path, file) {
|
|
116
|
+
const parent = dirname(path);
|
|
117
|
+
if (!existsSync(parent))
|
|
118
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
119
|
+
const json = JSON.stringify({ version: KEYS_VERSION, keys: file.keys }, null, 2) + '\n';
|
|
120
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
121
|
+
try {
|
|
122
|
+
writeFileSync(tmp, json, { mode: 0o600 });
|
|
123
|
+
renameSync(tmp, path);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
try {
|
|
127
|
+
unlinkSync(tmp);
|
|
128
|
+
}
|
|
129
|
+
catch { /* ignore */ }
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function newId(existing) {
|
|
134
|
+
for (;;) {
|
|
135
|
+
const id = randomBytes(4).toString('hex');
|
|
136
|
+
if (!existing.keys.some((k) => k.id === id))
|
|
137
|
+
return id;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Mint a key. Returns the record (stored) and the secret (shown once). Mutates `file`. */
|
|
141
|
+
export function createKey(file, name, opts = {}) {
|
|
142
|
+
const trimmed = name.trim();
|
|
143
|
+
if (!KEY_NAME_RE.test(trimmed))
|
|
144
|
+
throw new Error(`invalid key name "${name}": letters, digits, _ - . only, up to 64, starting with a letter or digit`);
|
|
145
|
+
if (file.keys.some((k) => k.name === trimmed))
|
|
146
|
+
throw new Error(`a key named "${trimmed}" already exists (rotate it, or pick another name)`);
|
|
147
|
+
if (opts.seat !== undefined && !KEY_NAME_RE.test(opts.seat))
|
|
148
|
+
throw new Error(`invalid seat alias "${opts.seat}"`);
|
|
149
|
+
const secret = mintSecret();
|
|
150
|
+
const record = { id: newId(file), name: trimmed, hash: hashKey(secret), created: new Date(opts.now ?? Date.now()).toISOString() };
|
|
151
|
+
if (opts.seat)
|
|
152
|
+
record.seat = opts.seat;
|
|
153
|
+
if (opts.models && opts.models.length > 0)
|
|
154
|
+
record.models = opts.models.map((m) => m.trim()).filter(Boolean);
|
|
155
|
+
if (opts.expiresAt !== undefined) {
|
|
156
|
+
if (!Number.isFinite(opts.expiresAt) || opts.expiresAt <= (opts.now ?? Date.now()))
|
|
157
|
+
throw new Error('expiry must be in the future');
|
|
158
|
+
record.expires = new Date(opts.expiresAt).toISOString();
|
|
159
|
+
}
|
|
160
|
+
file.keys.push(record);
|
|
161
|
+
return { record, secret };
|
|
162
|
+
}
|
|
163
|
+
/** Mark a key refused. Returns false when there is no such key. The record stays, for the list. */
|
|
164
|
+
export function revokeKey(file, name) {
|
|
165
|
+
const k = file.keys.find((x) => x.name === name);
|
|
166
|
+
if (!k)
|
|
167
|
+
return false;
|
|
168
|
+
k.disabled = true;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
/** Forget a key entirely (list no longer shows it). */
|
|
172
|
+
export function deleteKey(file, name) {
|
|
173
|
+
const i = file.keys.findIndex((x) => x.name === name);
|
|
174
|
+
if (i < 0)
|
|
175
|
+
return false;
|
|
176
|
+
file.keys.splice(i, 1);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
/** New secret, same name, seat, models and expiry; the old secret stops working at once. */
|
|
180
|
+
export function rotateKey(file, name, now = Date.now()) {
|
|
181
|
+
const k = file.keys.find((x) => x.name === name);
|
|
182
|
+
if (!k)
|
|
183
|
+
return null;
|
|
184
|
+
const secret = mintSecret();
|
|
185
|
+
k.hash = hashKey(secret);
|
|
186
|
+
k.created = new Date(now).toISOString();
|
|
187
|
+
delete k.lastUsed;
|
|
188
|
+
delete k.disabled;
|
|
189
|
+
return { record: k, secret };
|
|
190
|
+
}
|
|
191
|
+
export function keyIsUsable(k, now = Date.now()) {
|
|
192
|
+
if (k.disabled)
|
|
193
|
+
return false;
|
|
194
|
+
if (k.expires && Date.parse(k.expires) <= now)
|
|
195
|
+
return false;
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The record a presented secret belongs to, or null. Compares hashes in
|
|
200
|
+
* constant time, every record every time, so a miss takes as long as a hit
|
|
201
|
+
* and neither the count of keys nor which one matched leaks through timing.
|
|
202
|
+
* A disabled or expired key matches nothing — the caller cannot tell it from
|
|
203
|
+
* a wrong secret, on purpose.
|
|
204
|
+
*/
|
|
205
|
+
export function matchKey(file, provided, now = Date.now()) {
|
|
206
|
+
if (typeof provided !== 'string' || provided.length === 0)
|
|
207
|
+
return null;
|
|
208
|
+
const h = Buffer.from(hashKey(provided), 'hex');
|
|
209
|
+
let found = null;
|
|
210
|
+
for (const k of file.keys) {
|
|
211
|
+
const stored = Buffer.from(k.hash, 'hex');
|
|
212
|
+
if (stored.length === h.length && timingSafeEqual(stored, h) && keyIsUsable(k, now))
|
|
213
|
+
found = k;
|
|
214
|
+
}
|
|
215
|
+
return found;
|
|
216
|
+
}
|
|
217
|
+
/** `models` entries are exact ids, or `prefix*`; case-insensitive. Absent list = any model. */
|
|
218
|
+
export function keyAllowsModel(k, model) {
|
|
219
|
+
if (!k.models || k.models.length === 0)
|
|
220
|
+
return true;
|
|
221
|
+
const m = (model ?? '').toLowerCase();
|
|
222
|
+
for (const entry of k.models) {
|
|
223
|
+
const e = entry.toLowerCase();
|
|
224
|
+
if (e.endsWith('*') ? m.startsWith(e.slice(0, -1)) : m === e)
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
export function publicKey(k, now = Date.now()) {
|
|
230
|
+
const status = k.disabled ? 'revoked' : k.expires && Date.parse(k.expires) <= now ? 'expired' : 'active';
|
|
231
|
+
return { id: k.id, name: k.name, created: k.created, last_used: k.lastUsed ?? null, status, expires: k.expires ?? null, seat: k.seat ?? null, models: k.models ?? [] };
|
|
232
|
+
}
|
|
233
|
+
/** `--expires=30d` / `12h` / `2026-12-31` → epoch ms, or null when unparseable. */
|
|
234
|
+
export function parseExpiry(value, now = Date.now()) {
|
|
235
|
+
const v = value.trim();
|
|
236
|
+
const rel = /^(\d+)([hdw])$/i.exec(v);
|
|
237
|
+
if (rel) {
|
|
238
|
+
const n = Number(rel[1]);
|
|
239
|
+
const unit = rel[2].toLowerCase();
|
|
240
|
+
const ms = unit === 'h' ? 3_600_000 : unit === 'd' ? 86_400_000 : 7 * 86_400_000;
|
|
241
|
+
return n > 0 ? now + n * ms : null;
|
|
242
|
+
}
|
|
243
|
+
const abs = Date.parse(v);
|
|
244
|
+
return Number.isNaN(abs) ? null : abs;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The proxy's live view of the file. Reloads when the file's mtime moves (a
|
|
248
|
+
* stat per auth — the cost of "no restart"), records last-used with a
|
|
249
|
+
* debounced write that re-reads first so it never clobbers an edit the CLI
|
|
250
|
+
* made in between.
|
|
251
|
+
*/
|
|
252
|
+
export class KeyStore {
|
|
253
|
+
path;
|
|
254
|
+
file = emptyKeysFile();
|
|
255
|
+
mtimeMs = -1;
|
|
256
|
+
loadError = null;
|
|
257
|
+
dirtyLastUsed = new Map();
|
|
258
|
+
flushTimer = null;
|
|
259
|
+
constructor(path) {
|
|
260
|
+
this.path = path;
|
|
261
|
+
}
|
|
262
|
+
/** Read the file now. A malformed file is reported and leaves the last good state in place. */
|
|
263
|
+
load() {
|
|
264
|
+
let mtime = -1;
|
|
265
|
+
try {
|
|
266
|
+
mtime = statSync(this.path).mtimeMs;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
mtime = -1;
|
|
270
|
+
}
|
|
271
|
+
if (mtime === this.mtimeMs)
|
|
272
|
+
return;
|
|
273
|
+
try {
|
|
274
|
+
this.file = readKeysFile(this.path);
|
|
275
|
+
this.loadError = null;
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
this.loadError = err instanceof Error ? err.message : String(err);
|
|
279
|
+
}
|
|
280
|
+
this.mtimeMs = mtime;
|
|
281
|
+
}
|
|
282
|
+
get error() { return this.loadError; }
|
|
283
|
+
size() { return this.file.keys.length; }
|
|
284
|
+
list(now = Date.now()) {
|
|
285
|
+
this.load();
|
|
286
|
+
return this.file.keys.map((k) => publicKey(k, now));
|
|
287
|
+
}
|
|
288
|
+
/** The record a request's credential names, or null. Reloads first when the file moved. */
|
|
289
|
+
match(provided, now = Date.now()) {
|
|
290
|
+
this.load();
|
|
291
|
+
if (!provided || this.file.keys.length === 0)
|
|
292
|
+
return null;
|
|
293
|
+
return matchKey(this.file, provided, now);
|
|
294
|
+
}
|
|
295
|
+
/** Note a use; written to disk after a quiet moment. */
|
|
296
|
+
touch(k, now = Date.now()) {
|
|
297
|
+
const iso = new Date(now).toISOString();
|
|
298
|
+
k.lastUsed = iso;
|
|
299
|
+
this.dirtyLastUsed.set(k.id, iso);
|
|
300
|
+
if (!this.flushTimer) {
|
|
301
|
+
this.flushTimer = setTimeout(() => { this.flushTimer = null; this.flush(); }, KEYS_FLUSH_DELAY_MS);
|
|
302
|
+
this.flushTimer.unref?.();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Apply a mutation to the file on disk (re-read first), then adopt it. */
|
|
306
|
+
mutate(fn) {
|
|
307
|
+
const current = readKeysFile(this.path);
|
|
308
|
+
const out = fn(current);
|
|
309
|
+
writeKeysFile(this.path, current);
|
|
310
|
+
this.file = current;
|
|
311
|
+
try {
|
|
312
|
+
this.mtimeMs = statSync(this.path).mtimeMs;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
this.mtimeMs = -1;
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
/** Persist pending last-used stamps. Safe to call at any time; a no-op when nothing is pending. */
|
|
320
|
+
flush() {
|
|
321
|
+
if (this.dirtyLastUsed.size === 0)
|
|
322
|
+
return;
|
|
323
|
+
const pending = new Map(this.dirtyLastUsed);
|
|
324
|
+
this.dirtyLastUsed.clear();
|
|
325
|
+
try {
|
|
326
|
+
this.mutate((file) => {
|
|
327
|
+
for (const k of file.keys) {
|
|
328
|
+
const iso = pending.get(k.id);
|
|
329
|
+
if (iso && (!k.lastUsed || Date.parse(iso) > Date.parse(k.lastUsed)))
|
|
330
|
+
k.lastUsed = iso;
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
// The stamp is a convenience; losing it is not worth failing a request over.
|
|
336
|
+
console.error(`[dario] keys: could not record last-used: ${err instanceof Error ? err.message : err}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
close() {
|
|
340
|
+
if (this.flushTimer) {
|
|
341
|
+
clearTimeout(this.flushTimer);
|
|
342
|
+
this.flushTimer = null;
|
|
343
|
+
}
|
|
344
|
+
this.flush();
|
|
345
|
+
}
|
|
346
|
+
}
|
package/dist/ledger.d.ts
CHANGED
|
@@ -48,6 +48,29 @@ export interface LedgerFile {
|
|
|
48
48
|
updated: string;
|
|
49
49
|
/** `YYYY-MM-DD` (UTC) → model id → per-bucket totals. */
|
|
50
50
|
days: Record<string, Record<string, LedgerRow>>;
|
|
51
|
+
/**
|
|
52
|
+
* The same rows split by consumer (dario#1318): `YYYY-MM-DD` → consumer →
|
|
53
|
+
* model id → per-bucket totals. Only requests that named a consumer land
|
|
54
|
+
* here (a named key, the `x-dario-consumer` header, or the hashed user
|
|
55
|
+
* id), so the split never claims to sum to `days`. Absent on files from
|
|
56
|
+
* before 6.8 and read as empty.
|
|
57
|
+
*/
|
|
58
|
+
consumers?: Record<string, Record<string, Record<string, LedgerRow>>>;
|
|
59
|
+
}
|
|
60
|
+
/** One consumer's share of the lifetime number. */
|
|
61
|
+
export interface LedgerConsumerSummary {
|
|
62
|
+
requests: number;
|
|
63
|
+
apiEquivalentCost: number;
|
|
64
|
+
meteredCost: number;
|
|
65
|
+
recent: {
|
|
66
|
+
today: number;
|
|
67
|
+
last7d: number;
|
|
68
|
+
last30d: number;
|
|
69
|
+
};
|
|
70
|
+
/** `YYYY-MM-DD` of the consumer's most recent counted request. */
|
|
71
|
+
lastDay: string;
|
|
72
|
+
/** Models this consumer used, most requests first. */
|
|
73
|
+
models: string[];
|
|
51
74
|
}
|
|
52
75
|
export interface LedgerModelSummary {
|
|
53
76
|
provider: PricingProvider;
|
|
@@ -64,6 +87,8 @@ export interface LedgerModelSummary {
|
|
|
64
87
|
export interface LedgerSummary {
|
|
65
88
|
/** Where the file lives — so `dario usage` can say what it read. */
|
|
66
89
|
path: string;
|
|
90
|
+
/** The lifetime number split by consumer (named key, header, or hashed user id); empty when nothing named one. */
|
|
91
|
+
perConsumer: Record<string, LedgerConsumerSummary>;
|
|
67
92
|
since: string;
|
|
68
93
|
/** Distinct UTC days with traffic. */
|
|
69
94
|
days: number;
|
|
@@ -121,8 +146,10 @@ export declare function ledgerBucketFor(record: Pick<RequestRecord, 'status' | '
|
|
|
121
146
|
export declare function parseLedger(text: string): LedgerFile;
|
|
122
147
|
/** Add one record's tokens to the file in place. Returns false when it was not counted. */
|
|
123
148
|
export declare function addToLedger(file: LedgerFile, record: RequestRecord): boolean;
|
|
124
|
-
/** Drop the oldest days past LEDGER_MAX_DAYS. */
|
|
149
|
+
/** Drop the oldest days past LEDGER_MAX_DAYS, from the per-consumer split too. */
|
|
125
150
|
export declare function pruneLedger(file: LedgerFile, maxDays?: number): void;
|
|
151
|
+
/** The per-consumer split of a file, priced the same way as the headline. */
|
|
152
|
+
export declare function summarizeLedgerConsumers(file: LedgerFile, now?: number): Record<string, LedgerConsumerSummary>;
|
|
126
153
|
export declare function summarizeLedger(file: LedgerFile, path: string, now?: number): LedgerSummary;
|
|
127
154
|
/**
|
|
128
155
|
* Read a ledger file for display without a running proxy (`dario usage`
|
|
@@ -167,6 +194,12 @@ export declare function shortModelName(model: string): string;
|
|
|
167
194
|
* to match the rest of that command's output.
|
|
168
195
|
*/
|
|
169
196
|
export declare function formatLedgerSummary(s: LedgerSummary): string[];
|
|
197
|
+
/**
|
|
198
|
+
* `dario usage --by-key`: the lifetime number per consumer, biggest first.
|
|
199
|
+
* A consumer is a named key's name, an `x-dario-consumer` header, or the
|
|
200
|
+
* `u_…` hash of a client's user id — whichever named the request.
|
|
201
|
+
*/
|
|
202
|
+
export declare function formatLedgerConsumers(s: LedgerSummary, limit?: number): string[];
|
|
170
203
|
/**
|
|
171
204
|
* A share card: one SVG, 640×320, dark, the number in the middle. Plain
|
|
172
205
|
* system monospace so it renders the same in a README, a tweet screenshot
|
package/dist/ledger.js
CHANGED
|
@@ -115,7 +115,46 @@ export function parseLedger(text) {
|
|
|
115
115
|
}
|
|
116
116
|
const since = typeof raw.since === 'string' && !Number.isNaN(Date.parse(raw.since)) ? raw.since : new Date().toISOString();
|
|
117
117
|
const updated = typeof raw.updated === 'string' && !Number.isNaN(Date.parse(raw.updated)) ? raw.updated : since;
|
|
118
|
-
|
|
118
|
+
const file = { version: LEDGER_VERSION, since, updated, days };
|
|
119
|
+
if (raw.consumers && typeof raw.consumers === 'object') {
|
|
120
|
+
const consumers = {};
|
|
121
|
+
for (const [day, byConsumer] of Object.entries(raw.consumers)) {
|
|
122
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !byConsumer || typeof byConsumer !== 'object')
|
|
123
|
+
continue;
|
|
124
|
+
const cleanDay = {};
|
|
125
|
+
for (const [consumer, models] of Object.entries(byConsumer)) {
|
|
126
|
+
if (!consumer || !models || typeof models !== 'object')
|
|
127
|
+
continue;
|
|
128
|
+
const clean = {};
|
|
129
|
+
for (const [model, row] of Object.entries(models)) {
|
|
130
|
+
if (!row || typeof row !== 'object')
|
|
131
|
+
continue;
|
|
132
|
+
const r = {};
|
|
133
|
+
if (isCell(row.covered))
|
|
134
|
+
r.covered = { ...row.covered };
|
|
135
|
+
if (isCell(row.metered))
|
|
136
|
+
r.metered = { ...row.metered };
|
|
137
|
+
if (r.covered || r.metered)
|
|
138
|
+
clean[model] = r;
|
|
139
|
+
}
|
|
140
|
+
if (Object.keys(clean).length > 0)
|
|
141
|
+
cleanDay[consumer] = clean;
|
|
142
|
+
}
|
|
143
|
+
if (Object.keys(cleanDay).length > 0)
|
|
144
|
+
consumers[day] = cleanDay;
|
|
145
|
+
}
|
|
146
|
+
if (Object.keys(consumers).length > 0)
|
|
147
|
+
file.consumers = consumers;
|
|
148
|
+
}
|
|
149
|
+
return file;
|
|
150
|
+
}
|
|
151
|
+
function addCell(row, bucket, record) {
|
|
152
|
+
const cell = (row[bucket] ??= emptyCell());
|
|
153
|
+
cell.requests += 1;
|
|
154
|
+
cell.inputTokens += record.inputTokens;
|
|
155
|
+
cell.outputTokens += record.outputTokens;
|
|
156
|
+
cell.cacheReadTokens += record.cacheReadTokens;
|
|
157
|
+
cell.cacheCreateTokens += record.cacheCreateTokens;
|
|
119
158
|
}
|
|
120
159
|
/** Add one record's tokens to the file in place. Returns false when it was not counted. */
|
|
121
160
|
export function addToLedger(file, record) {
|
|
@@ -125,23 +164,76 @@ export function addToLedger(file, record) {
|
|
|
125
164
|
const day = dayKey(record.timestamp);
|
|
126
165
|
const model = record.model || 'unknown';
|
|
127
166
|
const models = (file.days[day] ??= {});
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
cell.cacheCreateTokens += record.cacheCreateTokens;
|
|
167
|
+
addCell((models[model] ??= {}), bucket, record);
|
|
168
|
+
if (record.consumer) {
|
|
169
|
+
const byConsumer = ((file.consumers ??= {})[day] ??= {});
|
|
170
|
+
const rows = (byConsumer[record.consumer] ??= {});
|
|
171
|
+
addCell((rows[model] ??= {}), bucket, record);
|
|
172
|
+
}
|
|
135
173
|
if (Date.parse(file.since) > record.timestamp)
|
|
136
174
|
file.since = new Date(record.timestamp).toISOString();
|
|
137
175
|
pruneLedger(file);
|
|
138
176
|
return true;
|
|
139
177
|
}
|
|
140
|
-
/** Drop the oldest days past LEDGER_MAX_DAYS. */
|
|
178
|
+
/** Drop the oldest days past LEDGER_MAX_DAYS, from the per-consumer split too. */
|
|
141
179
|
export function pruneLedger(file, maxDays = LEDGER_MAX_DAYS) {
|
|
142
180
|
const days = Object.keys(file.days).sort();
|
|
143
181
|
for (const day of days.slice(0, Math.max(0, days.length - maxDays)))
|
|
144
182
|
delete file.days[day];
|
|
183
|
+
if (file.consumers) {
|
|
184
|
+
const keep = new Set(Object.keys(file.days));
|
|
185
|
+
for (const day of Object.keys(file.consumers))
|
|
186
|
+
if (!keep.has(day))
|
|
187
|
+
delete file.consumers[day];
|
|
188
|
+
if (Object.keys(file.consumers).length === 0)
|
|
189
|
+
delete file.consumers;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** The per-consumer split of a file, priced the same way as the headline. */
|
|
193
|
+
export function summarizeLedgerConsumers(file, now = Date.now()) {
|
|
194
|
+
const today = dayKey(now);
|
|
195
|
+
const cutoff7 = dayKey(now - 6 * 86_400_000);
|
|
196
|
+
const cutoff30 = dayKey(now - 29 * 86_400_000);
|
|
197
|
+
const out = {};
|
|
198
|
+
for (const [day, byConsumer] of Object.entries(file.consumers ?? {})) {
|
|
199
|
+
const at = dayMs(day);
|
|
200
|
+
for (const [consumer, models] of Object.entries(byConsumer)) {
|
|
201
|
+
const c = (out[consumer] ??= { requests: 0, apiEquivalentCost: 0, meteredCost: 0, recent: { today: 0, last7d: 0, last30d: 0 }, lastDay: day, models: [], _models: {} });
|
|
202
|
+
if (day > c.lastDay)
|
|
203
|
+
c.lastDay = day;
|
|
204
|
+
for (const [model, row] of Object.entries(models)) {
|
|
205
|
+
if (row.covered) {
|
|
206
|
+
const cost = costOfTokens(model, at, row.covered);
|
|
207
|
+
c.apiEquivalentCost += cost;
|
|
208
|
+
c.requests += row.covered.requests;
|
|
209
|
+
c._models[model] = (c._models[model] ?? 0) + row.covered.requests;
|
|
210
|
+
if (day === today)
|
|
211
|
+
c.recent.today += cost;
|
|
212
|
+
if (day >= cutoff7)
|
|
213
|
+
c.recent.last7d += cost;
|
|
214
|
+
if (day >= cutoff30)
|
|
215
|
+
c.recent.last30d += cost;
|
|
216
|
+
}
|
|
217
|
+
if (row.metered) {
|
|
218
|
+
c.meteredCost += costOfTokens(model, at, row.metered);
|
|
219
|
+
c.requests += row.metered.requests;
|
|
220
|
+
c._models[model] = (c._models[model] ?? 0) + row.metered.requests;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const result = {};
|
|
226
|
+
for (const [consumer, c] of Object.entries(out)) {
|
|
227
|
+
result[consumer] = {
|
|
228
|
+
requests: c.requests,
|
|
229
|
+
apiEquivalentCost: round(c.apiEquivalentCost),
|
|
230
|
+
meteredCost: round(c.meteredCost),
|
|
231
|
+
recent: { today: round(c.recent.today), last7d: round(c.recent.last7d), last30d: round(c.recent.last30d) },
|
|
232
|
+
lastDay: c.lastDay,
|
|
233
|
+
models: Object.entries(c._models).sort((a, b) => b[1] - a[1]).map(([m]) => m),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
145
237
|
}
|
|
146
238
|
// Six places, not the window's four: a handful of gpt-5.6-luna requests is
|
|
147
239
|
// real money in the millionths and "$0 for 2 requests" reads as free.
|
|
@@ -217,6 +309,7 @@ export function summarizeLedger(file, path, now = Date.now()) {
|
|
|
217
309
|
perProvider,
|
|
218
310
|
perModel,
|
|
219
311
|
recent: { today: round(recent.today), last7d: round(recent.last7d), last30d: round(recent.last30d) },
|
|
312
|
+
perConsumer: summarizeLedgerConsumers(file, now),
|
|
220
313
|
};
|
|
221
314
|
}
|
|
222
315
|
/**
|
|
@@ -372,6 +465,26 @@ export function formatLedgerSummary(s) {
|
|
|
372
465
|
lines.push(` Paid per token on top (API key / extra usage): ${formatUsd(s.meteredCost)}`);
|
|
373
466
|
return lines;
|
|
374
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* `dario usage --by-key`: the lifetime number per consumer, biggest first.
|
|
470
|
+
* A consumer is a named key's name, an `x-dario-consumer` header, or the
|
|
471
|
+
* `u_…` hash of a client's user id — whichever named the request.
|
|
472
|
+
*/
|
|
473
|
+
export function formatLedgerConsumers(s, limit = 20) {
|
|
474
|
+
const entries = Object.entries(s.perConsumer).sort((a, b) => b[1].apiEquivalentCost - a[1].apiEquivalentCost);
|
|
475
|
+
if (entries.length === 0)
|
|
476
|
+
return [' By key: no request named a consumer yet (create keys with `dario keys create <name>`).'];
|
|
477
|
+
const lines = [];
|
|
478
|
+
lines.push(` By key (${entries.length} consumer${entries.length === 1 ? '' : 's'}; API-equivalent, lifetime · today · 7d · 30d):`);
|
|
479
|
+
const width = Math.min(24, Math.max(...entries.map(([c]) => c.length)));
|
|
480
|
+
for (const [consumer, c] of entries.slice(0, limit)) {
|
|
481
|
+
const models = c.models.slice(0, 2).map(shortModelName).join(', ');
|
|
482
|
+
lines.push(` ${consumer.slice(0, width).padEnd(width)} ${formatUsd(c.apiEquivalentCost).padStart(9)} · ${formatUsd(c.recent.today).padStart(8)} · ${formatUsd(c.recent.last7d).padStart(8)} · ${formatUsd(c.recent.last30d).padStart(8)} ${c.requests.toLocaleString('en-US')} req${c.requests === 1 ? '' : 's'}${models ? `, ${models}` : ''}${c.meteredCost > 0 ? `, ${formatUsd(c.meteredCost)} metered` : ''}`);
|
|
483
|
+
}
|
|
484
|
+
if (entries.length > limit)
|
|
485
|
+
lines.push(` … and ${entries.length - limit} more`);
|
|
486
|
+
return lines;
|
|
487
|
+
}
|
|
375
488
|
const escapeXml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
376
489
|
/**
|
|
377
490
|
* A share card: one SVG, 640×320, dark, the number in the middle. Plain
|
package/dist/proxy.d.ts
CHANGED
|
@@ -334,6 +334,16 @@ interface ProxyOptions {
|
|
|
334
334
|
* `DARIO_LEDGER=0` turns it off, `DARIO_LEDGER_PATH` moves the file.
|
|
335
335
|
*/
|
|
336
336
|
ledger?: boolean;
|
|
337
|
+
/**
|
|
338
|
+
* Named keys (v6.8, src/keys.ts, dario#1318): per-developer credentials
|
|
339
|
+
* in `~/.dario/keys.json`, hashes only, re-read when the file moves. A
|
|
340
|
+
* request that authenticates with one is attributed to it in /analytics,
|
|
341
|
+
* the ledger and the log, may prefer a seat, and may be held to a model
|
|
342
|
+
* allowlist. On by default; `--no-keys` / `DARIO_KEYS=0` ignores the file,
|
|
343
|
+
* `--keys-path` / `DARIO_KEYS_PATH` moves it.
|
|
344
|
+
*/
|
|
345
|
+
keys?: boolean;
|
|
346
|
+
keysPath?: string;
|
|
337
347
|
sessionIdleRotateMs?: number;
|
|
338
348
|
sessionRotateJitterMs?: number;
|
|
339
349
|
sessionMaxAgeMs?: number;
|
|
@@ -583,6 +593,7 @@ export interface ProxyLogEntry {
|
|
|
583
593
|
bucket?: string;
|
|
584
594
|
account?: string;
|
|
585
595
|
consumer?: string;
|
|
596
|
+
key?: string;
|
|
586
597
|
client?: string;
|
|
587
598
|
preserve_tools?: boolean;
|
|
588
599
|
stream?: boolean;
|