@askalf/dario 6.7.1 → 6.8.1

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/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
- return { version: LEDGER_VERSION, since, updated, days };
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
- 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;
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
376
489
  /**
377
490
  * A share card: one SVG, 640×320, dark, the number in the middle. Plain
@@ -496,7 +496,7 @@ export declare function detectDrift(t: TemplateData, installedOverride?: string
496
496
  */
497
497
  export declare const SUPPORTED_CC_RANGE: {
498
498
  readonly min: "1.0.0";
499
- readonly maxTested: "2.1.270";
499
+ readonly maxTested: "2.1.271";
500
500
  };
501
501
  /**
502
502
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -1194,7 +1194,7 @@ export function detectDrift(t, installedOverride) {
1194
1194
  */
1195
1195
  export const SUPPORTED_CC_RANGE = {
1196
1196
  min: '1.0.0',
1197
- maxTested: '2.1.270',
1197
+ maxTested: '2.1.271',
1198
1198
  };
1199
1199
  /**
1200
1200
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
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;