@askalf/dario 6.9.3 → 6.10.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/README.md CHANGED
@@ -382,7 +382,7 @@ dario keys create alice
382
382
  dario keys create bob --seat=bobs-max --models=claude-sonnet-5,claude-haiku*
383
383
  ```
384
384
 
385
- The secret is printed once and only its hash is kept, in `~/.dario/keys.json`. The request authenticated with alice's key *is* alice's in `/analytics`, in the ledger (`dario usage --by-key`) and on every log line; a key can prefer one pool seat (taken while it has headroom, normal routing otherwise, so a developer's conversations ride their own subscription) and can be held to a model allowlist (`403` before anything goes upstream, in either wire shape). The running proxy picks up a created, rotated or revoked key on its next request; the root `DARIO_API_KEY` keeps working beside them; `/admin/keys` does the same over HTTP. Details: [keys.md](./docs/keys.md).
385
+ Since 6.10 a key can carry a **daily budget** — `--budget=$5/day`, `--budget-tokens=2M/day` — read from the ledger, refused with a `429` and a `retry-after` at UTC midnight, with the headroom on every response as `x-dario-budget-*` headers ([details](./docs/keys.md#budgets)). The secret is printed once and only its hash is kept, in `~/.dario/keys.json`. The request authenticated with alice's key *is* alice's in `/analytics`, in the ledger (`dario usage --by-key`) and on every log line; a key can prefer one pool seat (taken while it has headroom, normal routing otherwise, so a developer's conversations ride their own subscription) and can be held to a model allowlist (`403` before anything goes upstream, in either wire shape). The running proxy picks up a created, rotated or revoked key on its next request; the root `DARIO_API_KEY` keeps working beside them; `/admin/keys` does the same over HTTP. Details: [keys.md](./docs/keys.md).
386
386
 
387
387
  ### Watch it happen
388
388
 
@@ -122,6 +122,13 @@ export interface AdminAccountLive {
122
122
  rejectedCount: number;
123
123
  /** Epoch ms of the most recent 429 on this account, or `null` if never. */
124
124
  lastRejectedAt: number | null;
125
+ /**
126
+ * Per-model buckets keeping their families off this seat while the seat
127
+ * itself still serves (`['oi']`: Fable parked on a Pro seat whose included
128
+ * overage is spent, Opus unaffected). Empty when none. Optional: a peer's
129
+ * snapshot from before the field behaves as before.
130
+ */
131
+ parkedBuckets?: string[];
125
132
  /** Organization observed on this seat's responses, or `null` if none yet (dario#1244). */
126
133
  organizationId: string | null;
127
134
  /**
@@ -158,7 +165,7 @@ export interface AdminCodexAccountRecord extends CodexSeatState {
158
165
  needsRefresh: boolean;
159
166
  }
160
167
  export interface AdminAuditEvent {
161
- action: 'login_start' | 'login_complete' | 'account_remove' | 'auth_reject' | 'rate_limited' | 'key_create' | 'key_revoke' | 'key_rotate';
168
+ action: 'login_start' | 'login_complete' | 'account_remove' | 'auth_reject' | 'rate_limited' | 'key_create' | 'key_revoke' | 'key_rotate' | 'key_budget';
162
169
  /** Which engine's credentials the event touched; absent means Claude, the only engine before codex joined (dario#1009). */
163
170
  engine?: 'codex';
164
171
  ok: boolean;
package/dist/admin-api.js CHANGED
@@ -4,7 +4,27 @@ import { startAddAccount, completeAddAccount, removeAccount, listAccountAliases,
4
4
  import { startAddCodexAccount, completeAddCodexAccount, removeCodexAccount, loadAllCodexAccounts, listCodexAccountAliases, codexAccountNeedsRefresh, codexSeatStatus, parseCodexManualPaste, } from './codex-accounts.js';
5
5
  import { parseManualPaste } from './oauth.js';
6
6
  import { grantAge } from './refresh-grant.js';
7
- import { createKey, revokeKey, rotateKey, parseExpiry, publicKey, KEY_NAME_RE } from './keys.js';
7
+ import { createKey, revokeKey, rotateKey, parseExpiry, publicKey, setKeyBudget, normalizeBudget, KEY_NAME_RE } from './keys.js';
8
+ /**
9
+ * `{ budget_usd_per_day, budget_tokens_per_day }` (numbers, or numeric strings)
10
+ * → a KeyBudget; undefined when neither is present; throws with a 400-worthy
11
+ * message when one is present and not a positive number.
12
+ */
13
+ function budgetFromBody(body) {
14
+ const num = (v, field) => {
15
+ if (v === undefined || v === null || v === '')
16
+ return undefined;
17
+ const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v.replace(/^\$/, '')) : NaN;
18
+ if (!Number.isFinite(n) || n <= 0)
19
+ throw new Error(`invalid "${field}": a positive number`);
20
+ return n;
21
+ };
22
+ const usdPerDay = num(body.budget_usd_per_day, 'budget_usd_per_day');
23
+ const tokensPerDay = num(body.budget_tokens_per_day, 'budget_tokens_per_day');
24
+ if (usdPerDay === undefined && tokensPerDay === undefined)
25
+ return undefined;
26
+ return normalizeBudget({ usdPerDay, tokensPerDay });
27
+ }
8
28
  const PENDING_TTL_MS = 10 * 60_000;
9
29
  const MAX_PENDING = 64; // backstop against unbounded growth (distinct aliases)
10
30
  const ACCOUNTS_PREFIX = '/admin/accounts/';
@@ -244,8 +264,10 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
244
264
  ? decodeURIComponent(urlPath.slice(KEYS_PREFIX.length))
245
265
  : null;
246
266
  const isKeyRotate = keyTarget !== null && keyTarget.endsWith('/rotate');
247
- const keyName = keyTarget === null ? null : isKeyRotate ? keyTarget.slice(0, -'/rotate'.length) : keyTarget;
248
- const isKeyRevoke = keyTarget !== null && !isKeyRotate && method === 'DELETE';
267
+ // POST /admin/keys/<name>/budget set or clear a key's daily caps (dario#1318 follow-up).
268
+ const isKeyBudget = keyTarget !== null && keyTarget.endsWith('/budget');
269
+ const keyName = keyTarget === null ? null : isKeyRotate ? keyTarget.slice(0, -'/rotate'.length) : isKeyBudget ? keyTarget.slice(0, -'/budget'.length) : keyTarget;
270
+ const isKeyRevoke = keyTarget !== null && !isKeyRotate && !isKeyBudget && method === 'DELETE';
249
271
  const known = urlPath === '/admin/login/start' ||
250
272
  urlPath === '/admin/login/start-needed' ||
251
273
  urlPath === '/admin/login/complete' ||
@@ -257,6 +279,7 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
257
279
  isCodexAccountDelete ||
258
280
  isAccountDelete ||
259
281
  isKeyRotate ||
282
+ isKeyBudget ||
260
283
  isKeyRevoke;
261
284
  if (!known)
262
285
  return false;
@@ -289,7 +312,7 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
289
312
  // accounts' credentials for the price of one throttle token.
290
313
  const isMutation = urlPath === '/admin/login/start' || isAccountDelete
291
314
  || urlPath === '/admin/codex/login/start' || isCodexAccountDelete
292
- || (urlPath === '/admin/keys' && method === 'POST') || isKeyRotate || isKeyRevoke;
315
+ || (urlPath === '/admin/keys' && method === 'POST') || isKeyRotate || isKeyBudget || isKeyRevoke;
293
316
  if (isMutation) {
294
317
  const wait = deps.rateLimit?.('mutation') ?? 0;
295
318
  if (wait > 0) {
@@ -546,6 +569,7 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
546
569
  request_count: l.requestCount,
547
570
  rejected_count: l.rejectedCount ?? 0,
548
571
  last_rejected_at: l.lastRejectedAt ?? null,
572
+ parked_buckets: l.parkedBuckets ?? [],
549
573
  consecutive_auth_failures: l.consecutiveAuthFailures,
550
574
  } : {}),
551
575
  };
@@ -609,8 +633,16 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
609
633
  }
610
634
  expiresAt = parsed;
611
635
  }
636
+ let budget;
637
+ try {
638
+ budget = budgetFromBody(body);
639
+ }
640
+ catch (err) {
641
+ send(res, 400, { error: err.message });
642
+ return true;
643
+ }
612
644
  try {
613
- const made = store.mutate((file) => createKey(file, name, { seat, models, expiresAt, now }));
645
+ const made = store.mutate((file) => createKey(file, name, { seat, models, expiresAt, budget, now }));
614
646
  deps.audit?.({ action: 'key_create', ok: true, status: 201, key: made.record.name, remote, detail: seat ? `seat=${seat}` : undefined });
615
647
  send(res, 201, { key: publicKey(made.record, now), secret: made.secret, note: 'the secret is shown once and is not stored' });
616
648
  }
@@ -645,6 +677,30 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
645
677
  send(res, 200, { key: publicKey(rotated.record, now), secret: rotated.secret, note: 'the secret is shown once and is not stored' });
646
678
  return true;
647
679
  }
680
+ // POST /admin/keys/<name>/budget { budget_usd_per_day?, budget_tokens_per_day? } — both absent/null clears.
681
+ if (isKeyBudget) {
682
+ if (method !== 'POST') {
683
+ send(res, 405, { error: 'Method not allowed (use POST)' });
684
+ return true;
685
+ }
686
+ const body = await readJsonBody(req);
687
+ let budget;
688
+ try {
689
+ budget = budgetFromBody(body);
690
+ }
691
+ catch (err) {
692
+ send(res, 400, { error: err.message });
693
+ return true;
694
+ }
695
+ const updated = store.mutate((file) => setKeyBudget(file, keyName, budget ?? null));
696
+ deps.audit?.({ action: 'key_budget', ok: updated !== null, status: updated ? 200 : 404, key: keyName, remote, detail: budget ? JSON.stringify(budget) : 'cleared' });
697
+ if (!updated) {
698
+ send(res, 404, { error: `no key named "${keyName}"` });
699
+ return true;
700
+ }
701
+ send(res, 200, { key: publicKey(updated, now) });
702
+ return true;
703
+ }
648
704
  // DELETE /admin/keys/<name> — revoked, kept for the list.
649
705
  if (isKeyRevoke) {
650
706
  const revoked = store.mutate((file) => revokeKey(file, keyName));
@@ -48,6 +48,14 @@ export declare const CC_TOOL_DEFINITIONS: {
48
48
  export declare const CC_TOOL_DEFINITIONS_UNION: {
49
49
  name: string;
50
50
  }[];
51
+ /**
52
+ * The most the template can add to an outbound prompt, in bytes: the largest
53
+ * system prompt the bundle carries plus every advertisable tool definition.
54
+ * The key-budget reservation (keys.ts requestBudgetReservation) adds this to
55
+ * the client's own body so a request is bounded by what dario SENDS, not by
56
+ * what the client sent — the template's prompt is billed to the key too.
57
+ */
58
+ export declare const CC_TEMPLATE_PROMPT_BYTES: number;
51
59
  /** Every name the bundle knows — including one whose definition is not advertisable (dario#1376). */
52
60
  export declare const CC_NATIVE_NAMES_UNION: Set<string>;
53
61
  /** CC's own tool names, EXACT case ("Read", "Bash", "Agent", …). A CC client's
@@ -62,6 +62,18 @@ export const CC_TOOL_DEFINITIONS = filterToolsForPlatform(TEMPLATE.tools.filter(
62
62
  * with no client declaration to mirror: the full-template fallback, the
63
63
  * merge-mode base array, and Fable's no-tools shape. */
64
64
  export const CC_TOOL_DEFINITIONS_UNION = TEMPLATE.tools.filter(isAdvertisableToolDefinition);
65
+ /**
66
+ * The most the template can add to an outbound prompt, in bytes: the largest
67
+ * system prompt the bundle carries plus every advertisable tool definition.
68
+ * The key-budget reservation (keys.ts requestBudgetReservation) adds this to
69
+ * the client's own body so a request is bounded by what dario SENDS, not by
70
+ * what the client sent — the template's prompt is billed to the key too.
71
+ */
72
+ export const CC_TEMPLATE_PROMPT_BYTES = (() => {
73
+ const t = TEMPLATE;
74
+ const sizes = [JSON.stringify(t.system_prompt ?? '').length, ...Object.values(t.system_prompt_variants ?? {}).map((v) => JSON.stringify(v ?? '').length)];
75
+ return Math.max(0, ...sizes) + JSON.stringify(CC_TOOL_DEFINITIONS_UNION).length;
76
+ })();
65
77
  /** Every name the bundle knows — including one whose definition is not advertisable (dario#1376). */
66
78
  export const CC_NATIVE_NAMES_UNION = new Set(TEMPLATE.tools.map((t) => String(t.name)));
67
79
  /** CC's own tool names, EXACT case ("Read", "Bash", "Agent", …). A CC client's
package/dist/cli.js CHANGED
@@ -19,7 +19,38 @@
19
19
  import { unlink, writeFile } from 'node:fs/promises';
20
20
  import { formatLedgerSummary, formatLedgerConsumers, formatUsd, renderLedgerCard, readLedgerFile, resolveLedgerPath, summarizeLedger } from './ledger.js';
21
21
  import { renderSpendDonuts } from './donuts.js';
22
- import { KeyStore, createKey, revokeKey, rotateKey, deleteKey, parseExpiry, publicKey, resolveKeysPath, KEY_NAME_RE } from './keys.js';
22
+ import { KeyStore, createKey, revokeKey, rotateKey, deleteKey, parseExpiry, publicKey, resolveKeysPath, KEY_NAME_RE, setKeyBudget, parseUsdBudget, parseTokenBudget, formatBudget } from './keys.js';
23
+ /**
24
+ * `--budget=$5/day` / `--budget-tokens=2M/day` → a KeyBudget, or undefined when
25
+ * neither flag is present. A flag that does not parse exits 1 with the accepted
26
+ * forms, like --expires does.
27
+ */
28
+ function readBudgetFlags(args) {
29
+ const usdArg = args.find((a) => a.startsWith('--budget='));
30
+ const tokArg = args.find((a) => a.startsWith('--budget-tokens='));
31
+ if (!usdArg && !tokArg)
32
+ return undefined;
33
+ const budget = {};
34
+ if (usdArg) {
35
+ const v = usdArg.slice('--budget='.length);
36
+ const n = parseUsdBudget(v);
37
+ if (n === null) {
38
+ console.error(`[dario] --budget: "${v}" is not a dollar amount per day ($5, 5.00, $5/day).`);
39
+ process.exit(1);
40
+ }
41
+ budget.usdPerDay = n;
42
+ }
43
+ if (tokArg) {
44
+ const v = tokArg.slice('--budget-tokens='.length);
45
+ const n = parseTokenBudget(v);
46
+ if (n === null) {
47
+ console.error(`[dario] --budget-tokens: "${v}" is not a token count per day (250k, 2M, 2000000).`);
48
+ process.exit(1);
49
+ }
50
+ budget.tokensPerDay = n;
51
+ }
52
+ return budget;
53
+ }
23
54
  import { loadAllAccounts as loadAllAccountsForIdentity, regenerateClientIdentity } from './accounts.js';
24
55
  import { maskEmail, parsePoolHeadroomFloor } from './pool.js';
25
56
  import { realpathSync, readFileSync } from 'node:fs';
@@ -785,9 +816,9 @@ async function keys() {
785
816
  return;
786
817
  }
787
818
  const w = Math.max(4, ...list.map((k) => k.name.length));
788
- console.log(` ${'NAME'.padEnd(w)} ${'STATUS'.padEnd(7)} ${'SEAT'.padEnd(12)} ${'LAST USED'.padEnd(10)} ${'EXPIRES'.padEnd(10)} MODELS`);
819
+ console.log(` ${'NAME'.padEnd(w)} ${'STATUS'.padEnd(7)} ${'SEAT'.padEnd(12)} ${'LAST USED'.padEnd(10)} ${'EXPIRES'.padEnd(10)} ${'BUDGET'.padEnd(20)} MODELS`);
789
820
  for (const k of list) {
790
- console.log(` ${k.name.padEnd(w)} ${k.status.padEnd(7)} ${(k.seat ?? '-').padEnd(12)} ${fmtAgo(k.last_used).padEnd(10)} ${fmtDay(k.expires).padEnd(10)} ${k.models.length ? k.models.join(', ') : 'any'}`);
821
+ console.log(` ${k.name.padEnd(w)} ${k.status.padEnd(7)} ${(k.seat ?? '-').padEnd(12)} ${fmtAgo(k.last_used).padEnd(10)} ${fmtDay(k.expires).padEnd(10)} ${formatBudget(k.budget ? { usdPerDay: k.budget.usd_per_day ?? undefined, tokensPerDay: k.budget.tokens_per_day ?? undefined } : null).padEnd(20)} ${k.models.length ? k.models.join(', ') : 'any'}`);
791
822
  }
792
823
  console.log('');
793
824
  console.log(` ${list.length} key${list.length === 1 ? '' : 's'} in ${path}. Spend per key: dario usage --by-key`);
@@ -798,7 +829,7 @@ async function keys() {
798
829
  const name = args[2];
799
830
  if (!name || name.startsWith('--')) {
800
831
  console.error('');
801
- console.error(' Usage: dario keys create <name> [--seat=<alias>] [--models=a,b,prefix*] [--expires=30d|12h|2w|<ISO date>]');
832
+ console.error(' Usage: dario keys create <name> [--seat=<alias>] [--models=a,b,prefix*] [--expires=30d|12h|2w|<ISO date>] [--budget=$5/day] [--budget-tokens=2M/day]');
802
833
  console.error('');
803
834
  process.exit(1);
804
835
  }
@@ -816,8 +847,9 @@ async function keys() {
816
847
  }
817
848
  expiresAt = parsed;
818
849
  }
850
+ const budget = readBudgetFlags(args);
819
851
  try {
820
- const made = store.mutate((file) => createKey(file, name, { seat, models, expiresAt, now }));
852
+ const made = store.mutate((file) => createKey(file, name, { seat, models, expiresAt, budget, now }));
821
853
  printSecret('created', publicKey(made.record, now), made.secret);
822
854
  }
823
855
  catch (err) {
@@ -826,6 +858,33 @@ async function keys() {
826
858
  }
827
859
  return;
828
860
  }
861
+ // dario keys budget <name> --budget=$5/day --budget-tokens=2M/day | --clear
862
+ if (sub === 'budget') {
863
+ const name = args[2];
864
+ if (!name || !KEY_NAME_RE.test(name) || (!args.includes('--clear') && !args.some((a) => a.startsWith('--budget=') || a.startsWith('--budget-tokens=')))) {
865
+ console.error('');
866
+ console.error(' Usage: dario keys budget <name> [--budget=$5/day] [--budget-tokens=2M/day] | --clear');
867
+ console.error('');
868
+ console.error(' Caps are per UTC day and read from the ledger (dario usage --by-key); a request that');
869
+ console.error(' would start past a cap is refused with 429 until midnight UTC.');
870
+ console.error('');
871
+ process.exit(1);
872
+ }
873
+ try {
874
+ const budget = args.includes('--clear') ? null : (readBudgetFlags(args) ?? null);
875
+ const updated = store.mutate((file) => setKeyBudget(file, name, budget));
876
+ if (!updated) {
877
+ console.error(`[dario] No key named "${name}".`);
878
+ process.exit(1);
879
+ }
880
+ console.log(budget ? `[dario] Key "${name}" budget: ${formatBudget(updated.budget)}` : `[dario] Key "${name}" budget cleared.`);
881
+ }
882
+ catch (err) {
883
+ console.error(`[dario] ${err instanceof Error ? err.message : String(err)}`);
884
+ process.exit(1);
885
+ }
886
+ return;
887
+ }
829
888
  if (sub === 'rotate' || sub === 'revoke' || sub === 'remove' || sub === 'rm' || sub === 'delete') {
830
889
  const name = args[2];
831
890
  if (!name || !KEY_NAME_RE.test(name)) {
@@ -867,7 +926,7 @@ async function keys() {
867
926
  return;
868
927
  }
869
928
  console.error(`[dario] Unknown keys subcommand: ${sub}`);
870
- console.error('Usage: dario keys [list|create <name> [--seat=..] [--models=..] [--expires=..]|revoke <name>|rotate <name>|remove <name>] [--json] [--keys-path=<file>]');
929
+ console.error('Usage: dario keys [list|create <name> [--seat=..] [--models=..] [--expires=..] [--budget=..] [--budget-tokens=..]|budget <name> ..|revoke <name>|rotate <name>|remove <name>] [--json] [--keys-path=<file>]');
871
930
  process.exit(1);
872
931
  }
873
932
  /**
package/dist/keys.d.ts CHANGED
@@ -21,7 +21,109 @@ export interface KeyRecord {
21
21
  seat?: string;
22
22
  /** Model allowlist: exact ids, or `prefix*`. Empty / absent = any model. */
23
23
  models?: string[];
24
+ /** Daily caps, UTC day, enforced from the ledger (dario#1318 follow-up). Absent = unlimited. */
25
+ budget?: KeyBudget;
24
26
  }
27
+ /**
28
+ * A key's daily budget. Both caps are per UTC day and both are read from the
29
+ * ledger's per-consumer rows at request time, so they survive a restart and
30
+ * every dollar can be traced to `dario usage --by-key`. `usdPerDay` is the
31
+ * API-equivalent price of the key's traffic (covered + metered); `tokensPerDay`
32
+ * counts every token the key sent or received, cache reads included.
33
+ */
34
+ export interface KeyBudget {
35
+ usdPerDay?: number;
36
+ tokensPerDay?: number;
37
+ }
38
+ /** What the ledger says a key has used today; the budget is compared against this. */
39
+ export interface KeyBudgetUsage {
40
+ usd: number;
41
+ tokens: number;
42
+ requests: number;
43
+ }
44
+ export interface KeyBudgetVerdict {
45
+ over: boolean;
46
+ /** Which cap tripped first. */
47
+ reason: 'usd' | 'tokens' | null;
48
+ /** Completed rows only — what the ledger has. */
49
+ usage: KeyBudgetUsage;
50
+ /** Requests admitted and not yet completed when this verdict was made, and what was reserved for them. */
51
+ inflight: KeyBudgetReservation;
52
+ /** `usage` plus the in-flight reservations — what `over` was decided on. */
53
+ projected: KeyBudgetUsage;
54
+ budget: KeyBudget;
55
+ /** Epoch ms of the next UTC midnight — when the day's counters reset. */
56
+ resetAt: number;
57
+ retryAfterSec: number;
58
+ }
59
+ /**
60
+ * What a request is charged against the budget while it is in flight: an
61
+ * UPPER BOUND on what it can cost, so a burst of admitted requests can never
62
+ * complete for more than the cap plus one request. The ledger prices a
63
+ * request only once its response is in; until then what dario will SEND
64
+ * bounds both sides. Prompt: the client's body plus whatever the template
65
+ * adds (system prompt, tool definitions), at BUDGET_BYTES_PER_TOKEN bytes per
66
+ * token, priced as cache-create — the highest input-side rate, so any mix of
67
+ * input, cache-read and cache-create tokens (all of which are prompt tokens,
68
+ * and so all inside this byte count) costs no more. Output: the max_tokens
69
+ * dario will put on the wire (the template's default when it pins one, the
70
+ * client's when it does not; BUDGET_DEFAULT_MAX_TOKENS when nothing is set),
71
+ * at the output rate — thinking is billed as output and lives under the same
72
+ * cap. Tokens reserve the same two counts.
73
+ */
74
+ export interface KeyBudgetReservation {
75
+ count: number;
76
+ usd: number;
77
+ tokens: number;
78
+ }
79
+ /** A conservative bytes-per-token for the reservation: prose is ~4, code and CJK are lower. */
80
+ export declare const BUDGET_BYTES_PER_TOKEN = 3;
81
+ /** Reserved output when the client sends no max_tokens / max_completion_tokens / max_output_tokens. */
82
+ export declare const BUDGET_DEFAULT_MAX_TOKENS = 8192;
83
+ export declare const EMPTY_RESERVATION: KeyBudgetReservation;
84
+ /**
85
+ * The reservation for one request, from what is known before it is sent.
86
+ * `priceOf` is analytics' costOfTokens, injected so this module stays free of
87
+ * the pricing table (the ledger injects the same way).
88
+ */
89
+ export declare function requestBudgetReservation(model: string, bodyBytes: number, maxTokens: number | null | undefined, priceOf: (model: string, atMs: number, cell: {
90
+ requests: number;
91
+ inputTokens: number;
92
+ outputTokens: number;
93
+ cacheReadTokens: number;
94
+ cacheCreateTokens: number;
95
+ }) => number, now?: number,
96
+ /** Bytes dario adds to the prompt beyond the client's body (the template's system prompt and tools). */
97
+ extraPromptBytes?: number): KeyBudgetReservation;
98
+ export declare function addReservation(a: KeyBudgetReservation, b: KeyBudgetReservation): KeyBudgetReservation;
99
+ export declare function subtractReservation(a: KeyBudgetReservation, b: KeyBudgetReservation): KeyBudgetReservation;
100
+ /** Throws on a cap that is not a positive finite number; returns undefined when neither cap is set. */
101
+ export declare function normalizeBudget(b: KeyBudget | null | undefined): KeyBudget | undefined;
102
+ /** `$5`, `5`, `5.00`, `$5/day`, `5/d` → dollars per day; null when unparseable. */
103
+ export declare function parseUsdBudget(value: string): number | null;
104
+ /** `250000`, `250k`, `2M`, `1.5m/day` → tokens per day (integer); null when unparseable. */
105
+ export declare function parseTokenBudget(value: string): number | null;
106
+ /** `{ usdPerDay: 5, tokensPerDay: 2_000_000 }` → `$5/day · 2.0M tok/day`; `-` for none. */
107
+ export declare function formatBudget(b: KeyBudget | null | undefined): string;
108
+ /** Next UTC midnight after `now`, epoch ms. */
109
+ export declare function nextUtcMidnight(now: number): number;
110
+ /**
111
+ * Over or under, given what the ledger has counted for the key today AND what
112
+ * is reserved for the requests already admitted but not yet completed. The
113
+ * ledger only knows a request once its response is in, so a burst of N
114
+ * simultaneous requests would all read the same completed total and all
115
+ * pass; every in-flight request is therefore held at its reservation — an
116
+ * upper bound on its cost (requestBudgetReservation) — until it completes.
117
+ * A request is admitted while completed + reserved is under the cap, so the
118
+ * most a key can complete in a day is the cap plus ONE request, whatever the
119
+ * burst size or the size of the requests in it. `retryAfterSec` is the time
120
+ * to the UTC day boundary, when the counters reset.
121
+ */
122
+ export declare function budgetVerdict(budget: KeyBudget, usage: KeyBudgetUsage, now?: number, inflight?: KeyBudgetReservation): KeyBudgetVerdict;
123
+ /** The response headers a budgeted key's request carries, served or refused. */
124
+ export declare function budgetHeaders(v: KeyBudgetVerdict, keyName: string): Record<string, string>;
125
+ /** Replace (or clear, with null) a key's budget. Null result: no such key. */
126
+ export declare function setKeyBudget(file: KeysFile, name: string, budget: KeyBudget | null): KeyRecord | null;
25
127
  export interface KeysFile {
26
128
  version: number;
27
129
  keys: KeyRecord[];
@@ -53,6 +155,8 @@ export declare function writeKeysFile(path: string, file: KeysFile): void;
53
155
  export interface CreateKeyOptions {
54
156
  seat?: string;
55
157
  models?: string[];
158
+ /** Daily caps; see KeyBudget. */
159
+ budget?: KeyBudget;
56
160
  /** Absolute expiry, epoch ms. */
57
161
  expiresAt?: number;
58
162
  now?: number;
@@ -92,6 +196,11 @@ export interface KeyPublic {
92
196
  expires: string | null;
93
197
  seat: string | null;
94
198
  models: string[];
199
+ /** Daily caps, or null when the key has none. */
200
+ budget: {
201
+ usd_per_day: number | null;
202
+ tokens_per_day: number | null;
203
+ } | null;
95
204
  }
96
205
  export declare function publicKey(k: KeyRecord, now?: number): KeyPublic;
97
206
  /** `--expires=30d` / `12h` / `2026-12-31` → epoch ms, or null when unparseable. */
package/dist/keys.js CHANGED
@@ -37,6 +37,131 @@ export const KEY_PREFIX = 'dk_';
37
37
  export const KEY_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_\-.]{0,63}$/;
38
38
  export const KEYS_FLUSH_DELAY_MS = 3_000;
39
39
  const SECRET_BYTES = 24;
40
+ /** A conservative bytes-per-token for the reservation: prose is ~4, code and CJK are lower. */
41
+ export const BUDGET_BYTES_PER_TOKEN = 3;
42
+ /** Reserved output when the client sends no max_tokens / max_completion_tokens / max_output_tokens. */
43
+ export const BUDGET_DEFAULT_MAX_TOKENS = 8_192;
44
+ export const EMPTY_RESERVATION = { count: 0, usd: 0, tokens: 0 };
45
+ /**
46
+ * The reservation for one request, from what is known before it is sent.
47
+ * `priceOf` is analytics' costOfTokens, injected so this module stays free of
48
+ * the pricing table (the ledger injects the same way).
49
+ */
50
+ export function requestBudgetReservation(model, bodyBytes, maxTokens, priceOf, now = Date.now(),
51
+ /** Bytes dario adds to the prompt beyond the client's body (the template's system prompt and tools). */
52
+ extraPromptBytes = 0) {
53
+ const inputTokens = Math.ceil((Math.max(0, bodyBytes) + Math.max(0, extraPromptBytes)) / BUDGET_BYTES_PER_TOKEN);
54
+ const outputTokens = Number.isFinite(maxTokens) && maxTokens > 0 ? Math.ceil(maxTokens) : BUDGET_DEFAULT_MAX_TOKENS;
55
+ const usd = priceOf(model, now, { requests: 1, inputTokens: 0, outputTokens, cacheReadTokens: 0, cacheCreateTokens: inputTokens });
56
+ return { count: 1, usd: Number.isFinite(usd) ? usd : 0, tokens: inputTokens + outputTokens };
57
+ }
58
+ export function addReservation(a, b) {
59
+ return { count: a.count + b.count, usd: a.usd + b.usd, tokens: a.tokens + b.tokens };
60
+ }
61
+ export function subtractReservation(a, b) {
62
+ const count = Math.max(0, a.count - b.count);
63
+ return count === 0 ? { ...EMPTY_RESERVATION } : { count, usd: Math.max(0, a.usd - b.usd), tokens: Math.max(0, a.tokens - b.tokens) };
64
+ }
65
+ /** Throws on a cap that is not a positive finite number; returns undefined when neither cap is set. */
66
+ export function normalizeBudget(b) {
67
+ if (!b)
68
+ return undefined;
69
+ const out = {};
70
+ if (b.usdPerDay !== undefined && b.usdPerDay !== null) {
71
+ if (!Number.isFinite(b.usdPerDay) || b.usdPerDay <= 0)
72
+ throw new Error('budget: usdPerDay must be a positive number');
73
+ out.usdPerDay = Math.round(b.usdPerDay * 100) / 100;
74
+ }
75
+ if (b.tokensPerDay !== undefined && b.tokensPerDay !== null) {
76
+ if (!Number.isFinite(b.tokensPerDay) || b.tokensPerDay <= 0)
77
+ throw new Error('budget: tokensPerDay must be a positive number');
78
+ out.tokensPerDay = Math.round(b.tokensPerDay);
79
+ }
80
+ return out.usdPerDay === undefined && out.tokensPerDay === undefined ? undefined : out;
81
+ }
82
+ /** `$5`, `5`, `5.00`, `$5/day`, `5/d` → dollars per day; null when unparseable. */
83
+ export function parseUsdBudget(value) {
84
+ const m = /^\$?\s*(\d+(?:\.\d{1,2})?)\s*(?:\/\s*(?:day|d))?$/i.exec(value.trim());
85
+ if (!m)
86
+ return null;
87
+ const n = Number(m[1]);
88
+ return Number.isFinite(n) && n > 0 ? n : null;
89
+ }
90
+ /** `250000`, `250k`, `2M`, `1.5m/day` → tokens per day (integer); null when unparseable. */
91
+ export function parseTokenBudget(value) {
92
+ const m = /^(\d+(?:\.\d+)?)\s*([kKmM]?)\s*(?:tok(?:ens)?)?\s*(?:\/\s*(?:day|d))?$/.exec(value.trim());
93
+ if (!m)
94
+ return null;
95
+ const mult = m[2]?.toLowerCase() === 'k' ? 1_000 : m[2]?.toLowerCase() === 'm' ? 1_000_000 : 1;
96
+ const n = Math.round(Number(m[1]) * mult);
97
+ return Number.isFinite(n) && n > 0 ? n : null;
98
+ }
99
+ /** `{ usdPerDay: 5, tokensPerDay: 2_000_000 }` → `$5/day · 2.0M tok/day`; `-` for none. */
100
+ export function formatBudget(b) {
101
+ if (!b || (b.usdPerDay === undefined && b.tokensPerDay === undefined))
102
+ return '-';
103
+ const parts = [];
104
+ if (b.usdPerDay !== undefined)
105
+ parts.push(`$${b.usdPerDay % 1 === 0 ? b.usdPerDay : b.usdPerDay.toFixed(2)}/day`);
106
+ if (b.tokensPerDay !== undefined) {
107
+ const t = b.tokensPerDay;
108
+ const s = t >= 1_000_000 ? `${(t / 1_000_000).toFixed(t % 1_000_000 === 0 ? 0 : 1)}M` : t >= 1_000 ? `${(t / 1_000).toFixed(t % 1_000 === 0 ? 0 : 1)}k` : String(t);
109
+ parts.push(`${s} tok/day`);
110
+ }
111
+ return parts.join(' · ');
112
+ }
113
+ /** Next UTC midnight after `now`, epoch ms. */
114
+ export function nextUtcMidnight(now) {
115
+ const d = new Date(now);
116
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
117
+ }
118
+ /**
119
+ * Over or under, given what the ledger has counted for the key today AND what
120
+ * is reserved for the requests already admitted but not yet completed. The
121
+ * ledger only knows a request once its response is in, so a burst of N
122
+ * simultaneous requests would all read the same completed total and all
123
+ * pass; every in-flight request is therefore held at its reservation — an
124
+ * upper bound on its cost (requestBudgetReservation) — until it completes.
125
+ * A request is admitted while completed + reserved is under the cap, so the
126
+ * most a key can complete in a day is the cap plus ONE request, whatever the
127
+ * burst size or the size of the requests in it. `retryAfterSec` is the time
128
+ * to the UTC day boundary, when the counters reset.
129
+ */
130
+ export function budgetVerdict(budget, usage, now = Date.now(), inflight = EMPTY_RESERVATION) {
131
+ const resetAt = nextUtcMidnight(now);
132
+ const projected = { usd: usage.usd + inflight.usd, tokens: usage.tokens + inflight.tokens, requests: usage.requests + inflight.count };
133
+ let reason = null;
134
+ if (budget.usdPerDay !== undefined && projected.usd >= budget.usdPerDay)
135
+ reason = 'usd';
136
+ else if (budget.tokensPerDay !== undefined && projected.tokens >= budget.tokensPerDay)
137
+ reason = 'tokens';
138
+ return { over: reason !== null, reason, usage, inflight, projected, budget, resetAt, retryAfterSec: Math.max(1, Math.ceil((resetAt - now) / 1000)) };
139
+ }
140
+ /** The response headers a budgeted key's request carries, served or refused. */
141
+ export function budgetHeaders(v, keyName) {
142
+ const h = { 'x-dario-budget-key': keyName, 'x-dario-budget-resets-at': new Date(v.resetAt).toISOString(), 'x-dario-budget-inflight': String(v.inflight.count) };
143
+ if (v.budget.usdPerDay !== undefined) {
144
+ h['x-dario-budget-usd'] = String(v.budget.usdPerDay);
145
+ h['x-dario-budget-used-usd'] = v.usage.usd.toFixed(4);
146
+ }
147
+ if (v.budget.tokensPerDay !== undefined) {
148
+ h['x-dario-budget-tokens'] = String(v.budget.tokensPerDay);
149
+ h['x-dario-budget-used-tokens'] = String(v.usage.tokens);
150
+ }
151
+ return h;
152
+ }
153
+ /** Replace (or clear, with null) a key's budget. Null result: no such key. */
154
+ export function setKeyBudget(file, name, budget) {
155
+ const k = file.keys.find((x) => x.name === name);
156
+ if (!k)
157
+ return null;
158
+ const normalized = normalizeBudget(budget);
159
+ if (normalized)
160
+ k.budget = normalized;
161
+ else
162
+ delete k.budget;
163
+ return k;
164
+ }
40
165
  export function keysPathFor(home = homedir()) {
41
166
  return join(home, '.dario', 'keys.json');
42
167
  }
@@ -100,6 +225,16 @@ export function parseKeysFile(text) {
100
225
  if (models.length > 0)
101
226
  rec.models = models;
102
227
  }
228
+ // Daily caps (dario#1318 follow-up): kept only when they parse as positive numbers.
229
+ if (k.budget && typeof k.budget === 'object') {
230
+ const b = k.budget;
231
+ try {
232
+ const budget = normalizeBudget({ usdPerDay: typeof b.usdPerDay === 'number' ? b.usdPerDay : undefined, tokensPerDay: typeof b.tokensPerDay === 'number' ? b.tokensPerDay : undefined });
233
+ if (budget)
234
+ rec.budget = budget;
235
+ }
236
+ catch { /* a malformed cap is dropped, never a reason to refuse the whole file */ }
237
+ }
103
238
  seen.add(rec.name);
104
239
  keys.push(rec);
105
240
  }
@@ -152,6 +287,9 @@ export function createKey(file, name, opts = {}) {
152
287
  record.seat = opts.seat;
153
288
  if (opts.models && opts.models.length > 0)
154
289
  record.models = opts.models.map((m) => m.trim()).filter(Boolean);
290
+ const budget = normalizeBudget(opts.budget);
291
+ if (budget)
292
+ record.budget = budget;
155
293
  if (opts.expiresAt !== undefined) {
156
294
  if (!Number.isFinite(opts.expiresAt) || opts.expiresAt <= (opts.now ?? Date.now()))
157
295
  throw new Error('expiry must be in the future');
@@ -228,7 +366,8 @@ export function keyAllowsModel(k, model) {
228
366
  }
229
367
  export function publicKey(k, now = Date.now()) {
230
368
  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 ?? [] };
369
+ const budget = k.budget ? { usd_per_day: k.budget.usdPerDay ?? null, tokens_per_day: k.budget.tokensPerDay ?? null } : null;
370
+ return { id: k.id, name: k.name, created: k.created, last_used: k.lastUsed ?? null, status, expires: k.expires ?? null, seat: k.seat ?? null, budget, models: k.models ?? [] };
232
371
  }
233
372
  /** `--expires=30d` / `12h` / `2026-12-31` → epoch ms, or null when unparseable. */
234
373
  export function parseExpiry(value, now = Date.now()) {
package/dist/ledger.d.ts CHANGED
@@ -155,6 +155,17 @@ export declare function addToLedger(file: LedgerFile, record: RequestRecord): bo
155
155
  export declare function pruneLedger(file: LedgerFile, maxDays?: number): void;
156
156
  /** The per-consumer split of a file, priced the same way as the headline. */
157
157
  export declare function summarizeLedgerConsumers(file: LedgerFile, now?: number): Record<string, LedgerConsumerSummary>;
158
+ /**
159
+ * What one consumer has used so far TODAY (UTC), priced the same way as the
160
+ * headline: covered and metered rows both count, because a budget is about the
161
+ * traffic a key caused, not about who paid for it. Tokens are all four buckets.
162
+ * The key-budget check (src/keys.ts budgetVerdict) reads this per request.
163
+ */
164
+ export declare function consumerDayUsage(file: LedgerFile, consumer: string, now?: number): {
165
+ usd: number;
166
+ tokens: number;
167
+ requests: number;
168
+ };
158
169
  /** `1234` → `1.2k`, `1234567` → `1.2M`; below a thousand, the number itself. */
159
170
  export declare function formatTokenCount(n: number): string;
160
171
  export declare function summarizeLedger(file: LedgerFile, path: string, now?: number): LedgerSummary;
@@ -185,6 +196,12 @@ export declare class Ledger {
185
196
  /** Count a request. Returns false when it was not ledger material. */
186
197
  add(record: RequestRecord): boolean;
187
198
  summary(now?: number): LedgerSummary;
199
+ /** Today's usage for one consumer — the key-budget check's input. */
200
+ consumerToday(consumer: string, now?: number): {
201
+ usd: number;
202
+ tokens: number;
203
+ requests: number;
204
+ };
188
205
  /** The raw per-day table, for /analytics/ledger. */
189
206
  snapshot(): LedgerFile;
190
207
  private scheduleFlush;