@askalf/dario 6.9.2 → 6.10.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 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
 
@@ -158,7 +158,7 @@ export interface AdminCodexAccountRecord extends CodexSeatState {
158
158
  needsRefresh: boolean;
159
159
  }
160
160
  export interface AdminAuditEvent {
161
- action: 'login_start' | 'login_complete' | 'account_remove' | 'auth_reject' | 'rate_limited' | 'key_create' | 'key_revoke' | 'key_rotate';
161
+ action: 'login_start' | 'login_complete' | 'account_remove' | 'auth_reject' | 'rate_limited' | 'key_create' | 'key_revoke' | 'key_rotate' | 'key_budget';
162
162
  /** Which engine's credentials the event touched; absent means Claude, the only engine before codex joined (dario#1009). */
163
163
  engine?: 'codex';
164
164
  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) {
@@ -609,8 +632,16 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
609
632
  }
610
633
  expiresAt = parsed;
611
634
  }
635
+ let budget;
636
+ try {
637
+ budget = budgetFromBody(body);
638
+ }
639
+ catch (err) {
640
+ send(res, 400, { error: err.message });
641
+ return true;
642
+ }
612
643
  try {
613
- const made = store.mutate((file) => createKey(file, name, { seat, models, expiresAt, now }));
644
+ const made = store.mutate((file) => createKey(file, name, { seat, models, expiresAt, budget, now }));
614
645
  deps.audit?.({ action: 'key_create', ok: true, status: 201, key: made.record.name, remote, detail: seat ? `seat=${seat}` : undefined });
615
646
  send(res, 201, { key: publicKey(made.record, now), secret: made.secret, note: 'the secret is shown once and is not stored' });
616
647
  }
@@ -645,6 +676,30 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
645
676
  send(res, 200, { key: publicKey(rotated.record, now), secret: rotated.secret, note: 'the secret is shown once and is not stored' });
646
677
  return true;
647
678
  }
679
+ // POST /admin/keys/<name>/budget { budget_usd_per_day?, budget_tokens_per_day? } — both absent/null clears.
680
+ if (isKeyBudget) {
681
+ if (method !== 'POST') {
682
+ send(res, 405, { error: 'Method not allowed (use POST)' });
683
+ return true;
684
+ }
685
+ const body = await readJsonBody(req);
686
+ let budget;
687
+ try {
688
+ budget = budgetFromBody(body);
689
+ }
690
+ catch (err) {
691
+ send(res, 400, { error: err.message });
692
+ return true;
693
+ }
694
+ const updated = store.mutate((file) => setKeyBudget(file, keyName, budget ?? null));
695
+ deps.audit?.({ action: 'key_budget', ok: updated !== null, status: updated ? 200 : 404, key: keyName, remote, detail: budget ? JSON.stringify(budget) : 'cleared' });
696
+ if (!updated) {
697
+ send(res, 404, { error: `no key named "${keyName}"` });
698
+ return true;
699
+ }
700
+ send(res, 200, { key: publicKey(updated, now) });
701
+ return true;
702
+ }
648
703
  // DELETE /admin/keys/<name> — revoked, kept for the list.
649
704
  if (isKeyRevoke) {
650
705
  const revoked = store.mutate((file) => revokeKey(file, keyName));
@@ -16,11 +16,22 @@ export declare const CC_TEMPLATE: TemplateData;
16
16
  export declare function filterToolsForPlatform<T extends {
17
17
  name: string;
18
18
  }>(tools: T[], platform: string): T[];
19
+ /**
20
+ * A tool definition the API will accept: an `input_schema` object with a
21
+ * string `type`. A capture can carry less — the 2026-09-18T01:26Z rebake on
22
+ * CC v2.1.275 recorded `advisor` as `{"name":"advisor","description":"",
23
+ * "input_schema":{}}`, a remote-config tool caught half-loaded — and Fable
24
+ * refuses any request advertising it (`tools.0.custom.input_schema.type: Field
25
+ * required`, dario#1376) while other families let it through. The name stays
26
+ * KNOWN (CC_NATIVE_NAMES_UNION, identity mapping, the config-scoped
27
+ * preservation); the definition is never put on the wire.
28
+ */
29
+ export declare function isAdvertisableToolDefinition(def: unknown): boolean;
30
+ /** Names in the bundle whose definition is not advertisable (see above). Empty on a clean bake. */
31
+ export declare const CC_TOOL_DEFINITIONS_UNADVERTISABLE: Set<string>;
19
32
  /** CC's exact tool definitions for the current platform — filtered from the bundled union. */
20
33
  export declare const CC_TOOL_DEFINITIONS: {
21
34
  name: string;
22
- description: string;
23
- input_schema: Record<string, unknown>;
24
35
  }[];
25
36
  /** The UNFILTERED bundled union — every tool the bake knows across platforms
26
37
  * (PLATFORM_ONLY_TOOLS keeps the bundle a superset). The identity-mapping,
@@ -36,9 +47,16 @@ export declare const CC_TOOL_DEFINITIONS: {
36
47
  * merge-mode base array, and Fable's no-tools shape. */
37
48
  export declare const CC_TOOL_DEFINITIONS_UNION: {
38
49
  name: string;
39
- description: string;
40
- input_schema: Record<string, unknown>;
41
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;
59
+ /** Every name the bundle knows — including one whose definition is not advertisable (dario#1376). */
42
60
  export declare const CC_NATIVE_NAMES_UNION: Set<string>;
43
61
  /** CC's own tool names, EXACT case ("Read", "Bash", "Agent", …). A CC client's
44
62
  * tools identity-map to themselves and OVERRIDE TOOL_MAP — whose lowercase
@@ -29,8 +29,26 @@ export function filterToolsForPlatform(tools, platform) {
29
29
  return true;
30
30
  });
31
31
  }
32
+ /**
33
+ * A tool definition the API will accept: an `input_schema` object with a
34
+ * string `type`. A capture can carry less — the 2026-09-18T01:26Z rebake on
35
+ * CC v2.1.275 recorded `advisor` as `{"name":"advisor","description":"",
36
+ * "input_schema":{}}`, a remote-config tool caught half-loaded — and Fable
37
+ * refuses any request advertising it (`tools.0.custom.input_schema.type: Field
38
+ * required`, dario#1376) while other families let it through. The name stays
39
+ * KNOWN (CC_NATIVE_NAMES_UNION, identity mapping, the config-scoped
40
+ * preservation); the definition is never put on the wire.
41
+ */
42
+ export function isAdvertisableToolDefinition(def) {
43
+ if (!def || typeof def !== 'object')
44
+ return false;
45
+ const schema = def.input_schema;
46
+ return !!schema && typeof schema === 'object' && typeof schema.type === 'string';
47
+ }
48
+ /** Names in the bundle whose definition is not advertisable (see above). Empty on a clean bake. */
49
+ export const CC_TOOL_DEFINITIONS_UNADVERTISABLE = new Set(TEMPLATE.tools.filter((t) => !isAdvertisableToolDefinition(t)).map((t) => String(t.name)));
32
50
  /** CC's exact tool definitions for the current platform — filtered from the bundled union. */
33
- export const CC_TOOL_DEFINITIONS = filterToolsForPlatform(TEMPLATE.tools, process.platform);
51
+ export const CC_TOOL_DEFINITIONS = filterToolsForPlatform(TEMPLATE.tools.filter(isAdvertisableToolDefinition), process.platform);
34
52
  /** The UNFILTERED bundled union — every tool the bake knows across platforms
35
53
  * (PLATFORM_ONLY_TOOLS keeps the bundle a superset). The identity-mapping,
36
54
  * detection, and advertise paths intersect with what the CLIENT declared,
@@ -43,7 +61,20 @@ export const CC_TOOL_DEFINITIONS = filterToolsForPlatform(TEMPLATE.tools, proces
43
61
  * upstream). Host-filtered CC_TOOL_DEFINITIONS stays correct for the paths
44
62
  * with no client declaration to mirror: the full-template fallback, the
45
63
  * merge-mode base array, and Fable's no-tools shape. */
46
- export const CC_TOOL_DEFINITIONS_UNION = TEMPLATE.tools;
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
+ })();
77
+ /** Every name the bundle knows — including one whose definition is not advertisable (dario#1376). */
47
78
  export const CC_NATIVE_NAMES_UNION = new Set(TEMPLATE.tools.map((t) => String(t.name)));
48
79
  /** CC's own tool names, EXACT case ("Read", "Bash", "Agent", …). A CC client's
49
80
  * tools identity-map to themselves and OVERRIDE TOOL_MAP — whose lowercase
@@ -2068,8 +2099,14 @@ export function buildCCRequest(clientBody, billingTag, cacheControl, identity, o
2068
2099
  // authoritative source, never the template.
2069
2100
  const availableCC = CC_TOOL_DEFINITIONS_UNION.filter((t) => !isMcpToolName(t.name) && clientToolNames.has(t.name.toLowerCase()));
2070
2101
  const mcpTools = clientTools.filter((t) => isMcpToolName(t.name));
2071
- ccRequest.tools = availableCC.length > 0 || mcpTools.length > 0
2072
- ? dedupeToolsByName([...availableCC, ...mcpTools])
2102
+ // A CC-native name the bundle knows but cannot advertise (dario#1376:
2103
+ // `advisor` captured with an empty schema) is still identity-mapped
2104
+ // above; the only usable definition is the client's own, so it goes out
2105
+ // verbatim, as MCP tools do. The client's schema is what its parser
2106
+ // expects back in any case.
2107
+ const clientOwnNative = clientTools.filter((t) => typeof t.name === 'string' && CC_TOOL_DEFINITIONS_UNADVERTISABLE.has(t.name) && isAdvertisableToolDefinition(t));
2108
+ ccRequest.tools = availableCC.length > 0 || mcpTools.length > 0 || clientOwnNative.length > 0
2109
+ ? dedupeToolsByName([...availableCC, ...clientOwnNative, ...mcpTools])
2073
2110
  : CC_TOOL_DEFINITIONS;
2074
2111
  }
2075
2112
  }
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;
package/dist/ledger.js CHANGED
@@ -241,6 +241,31 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
241
241
  }
242
242
  return result;
243
243
  }
244
+ /**
245
+ * What one consumer has used so far TODAY (UTC), priced the same way as the
246
+ * headline: covered and metered rows both count, because a budget is about the
247
+ * traffic a key caused, not about who paid for it. Tokens are all four buckets.
248
+ * The key-budget check (src/keys.ts budgetVerdict) reads this per request.
249
+ */
250
+ export function consumerDayUsage(file, consumer, now = Date.now()) {
251
+ const day = dayKey(now);
252
+ const models = file.consumers?.[day]?.[consumer];
253
+ const out = { usd: 0, tokens: 0, requests: 0 };
254
+ if (!models)
255
+ return out;
256
+ const at = dayMs(day);
257
+ for (const [model, row] of Object.entries(models)) {
258
+ for (const cell of [row.covered, row.metered]) {
259
+ if (!cell)
260
+ continue;
261
+ out.usd += costOfTokens(model, at, cell);
262
+ out.tokens += cell.inputTokens + cell.outputTokens + cell.cacheReadTokens + cell.cacheCreateTokens;
263
+ out.requests += cell.requests;
264
+ }
265
+ }
266
+ out.usd = round(out.usd);
267
+ return out;
268
+ }
244
269
  function addTokens(into, cell) {
245
270
  into.inputTokens += cell.inputTokens;
246
271
  into.outputTokens += cell.outputTokens;
@@ -405,6 +430,10 @@ export class Ledger {
405
430
  summary(now = Date.now()) {
406
431
  return summarizeLedger(this.file, this.path, now);
407
432
  }
433
+ /** Today's usage for one consumer — the key-budget check's input. */
434
+ consumerToday(consumer, now = Date.now()) {
435
+ return consumerDayUsage(this.file, consumer, now);
436
+ }
408
437
  /** The raw per-day table, for /analytics/ledger. */
409
438
  snapshot() {
410
439
  return JSON.parse(JSON.stringify(this.file));
package/dist/metrics.d.ts CHANGED
@@ -19,6 +19,13 @@ export interface MetricsInput {
19
19
  /** Most recent records, newest last — the latency quantiles come from these. */
20
20
  recent: readonly RequestRecord[];
21
21
  version: string;
22
+ /** Per-key daily budgets and today's use (keys with a budget only); absent when keys or the ledger are off. */
23
+ budgets?: Record<string, {
24
+ usdPerDay: number | null;
25
+ tokensPerDay: number | null;
26
+ usedUsd: number;
27
+ usedTokens: number;
28
+ }>;
22
29
  }
23
30
  /** Nearest-rank quantile over a sorted ascending array. */
24
31
  export declare function quantile(sorted: readonly number[], q: number): number;
package/dist/metrics.js CHANGED
@@ -117,6 +117,14 @@ export function renderPrometheus(input) {
117
117
  out.push(`${fam.name}_count ${vals.length}`);
118
118
  }
119
119
  }
120
+ // ---- per-key daily budgets (dario#1318 follow-up) ----------------------
121
+ const budgets = Object.entries(input.budgets ?? {});
122
+ if (budgets.length > 0) {
123
+ metric('dario_key_budget_usd_per_day', 'Daily API-equivalent cap per named key, USD (keys with a dollar cap).', budgets.filter(([, b]) => b.usdPerDay !== null).map(([key, b]) => [{ key }, b.usdPerDay]));
124
+ metric('dario_key_budget_used_usd', 'API-equivalent spend per budgeted key today (UTC), USD.', budgets.map(([key, b]) => [{ key }, b.usedUsd]));
125
+ metric('dario_key_budget_tokens_per_day', 'Daily token cap per named key (keys with a token cap).', budgets.filter(([, b]) => b.tokensPerDay !== null).map(([key, b]) => [{ key }, b.tokensPerDay]));
126
+ metric('dario_key_budget_used_tokens', 'Tokens per budgeted key today (UTC), all buckets.', budgets.map(([key, b]) => [{ key }, b.usedTokens]));
127
+ }
120
128
  // ---- predictions -------------------------------------------------------
121
129
  const p = summary.predictions;
122
130
  if (p.estimatedExhaustionMinutes !== null) {
package/dist/proxy.js CHANGED
@@ -9,18 +9,18 @@ import { getAccessToken, getStatus, ignoreCcCredentials } from './oauth.js';
9
9
  import { buildHealthResponse, derivePoolStatus, probeRequested, shouldDiscloseHealthInternals, shouldRunServingProbe } from './health-response.js';
10
10
  import { getServingProbe } from './serving-probe.js';
11
11
  import { darioVersion } from './version.js';
12
- import { buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
12
+ import { CC_TOOL_DEFINITIONS_UNADVERTISABLE, CC_TEMPLATE_PROMPT_BYTES, resolveMaxTokens, buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
13
13
  import { stampCch, hasCchSeed } from './cch.js';
14
14
  import { foldTiming, timingHeaders, timingLogFields } from './timing.js';
15
15
  import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
16
16
  import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, resolvePoolHeadroomFloor, DEFAULT_POOL_HEADROOM_FLOOR, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail, isAccountEligible } from './pool.js';
17
17
  import { backfillIdentity } from './accounts.js';
18
18
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
19
- import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
19
+ import { Analytics, billingBucketFromClaim, costOfTokens, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
20
20
  import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
21
21
  import { renderPrometheus } from './metrics.js';
22
22
  import { renderSpendDonuts, renderAnalyticsView, ANALYTICS_UI_SHELL } from './donuts.js';
23
- import { KeyStore, keyAllowsModel, resolveKeysPath, looksLikeNamedKey } from './keys.js';
23
+ import { KeyStore, keyAllowsModel, resolveKeysPath, looksLikeNamedKey, budgetVerdict, budgetHeaders, requestBudgetReservation, addReservation, subtractReservation, EMPTY_RESERVATION } from './keys.js';
24
24
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
25
25
  import { notify as osNotify } from './notify.js';
26
26
  import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
@@ -2032,6 +2032,38 @@ export async function startProxy(opts = {}) {
2032
2032
  else if (keyStore.size() > 0)
2033
2033
  console.log(`[dario] keys: ${keyStore.size()} named key${keyStore.size() === 1 ? '' : 's'} from ${keyStore.path}`);
2034
2034
  }
2035
+ /**
2036
+ * Per-key daily budgets (dario#1318 follow-up) read the ledger's per-consumer
2037
+ * rows; with the ledger off there is nothing to read, so a budget cannot be
2038
+ * enforced. Say so once at startup rather than silently letting traffic through.
2039
+ */
2040
+ if (keyStore && !ledger) {
2041
+ const budgeted = keyStore.list().filter((k) => k.budget !== null).map((k) => k.name);
2042
+ if (budgeted.length > 0)
2043
+ console.error(`[dario] keys: budgets on ${budgeted.join(', ')} are NOT enforced — the ledger is off (--no-ledger / DARIO_LEDGER=0) and budgets are read from it`);
2044
+ }
2045
+ /**
2046
+ * What is reserved for the requests admitted under a key's budget and not
2047
+ * yet completed, per key: each at an upper bound on its cost (keys.ts
2048
+ * requestBudgetReservation), so a burst can never complete for more than
2049
+ * the cap plus one request (review of #1378). Released in the handler's
2050
+ * finally on every exit.
2051
+ */
2052
+ const keyInflight = new Map();
2053
+ /** Every budgeted key with today's use, for /analytics and /metrics. Empty when keys or the ledger are off. */
2054
+ const keyBudgetsSnapshot = () => {
2055
+ const out = {};
2056
+ if (!keyStore || !ledger)
2057
+ return out;
2058
+ keyStore.load();
2059
+ for (const k of keyStore.list()) {
2060
+ if (!k.budget)
2061
+ continue;
2062
+ const used = ledger.consumerToday(k.name);
2063
+ out[k.name] = { usdPerDay: k.budget.usd_per_day, tokensPerDay: k.budget.tokens_per_day, usedUsd: used.usd, usedTokens: used.tokens };
2064
+ }
2065
+ return out;
2066
+ };
2035
2067
  // Admin API (#599) — opt-in headless account management at /admin/*. Off
2036
2068
  // unless DARIO_ADMIN=1. Auth is ALWAYS required (even on loopback) because
2037
2069
  // these endpoints add/remove OAuth accounts: the admin token is
@@ -2737,13 +2769,14 @@ export async function startProxy(opts = {}) {
2737
2769
  // `queue` rides along the summary (dario#905): request-queue.ts always
2738
2770
  // documented snapshot() as "exposed for /analytics", but it was never
2739
2771
  // actually wired in, so slot exhaustion was invisible from outside.
2740
- res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), lifetime: ledger ? ledger.summary() : null }));
2772
+ res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), lifetime: ledger ? ledger.summary() : null, budgets: keyBudgetsSnapshot() }));
2741
2773
  return;
2742
2774
  }
2743
2775
  // Prometheus text exposition of the same state (dario#1341). A view, not
2744
2776
  // new collection: a scrape costs what GET /analytics costs. Same gate.
2745
2777
  if (urlPath === '/metrics' && req.method === 'GET') {
2746
2778
  const body = renderPrometheus({
2779
+ budgets: keyBudgetsSnapshot(),
2747
2780
  summary: analytics.summary(),
2748
2781
  queue: queue.snapshot(),
2749
2782
  lifetime: ledger ? ledger.summary() : null,
@@ -3004,6 +3037,11 @@ export async function startProxy(opts = {}) {
3004
3037
  // "overhead" never has to be guessed at (src/timing.ts).
3005
3038
  let queueMs = 0;
3006
3039
  let pacingMs = 0;
3040
+ // The key this request was admitted under with a budget and what was
3041
+ // reserved for it, for the release in the finally below; null when no
3042
+ // budget applied.
3043
+ let budgetInflightKey = null;
3044
+ let budgetReserved = EMPTY_RESERVATION;
3007
3045
  const releaseQueueSlot = () => {
3008
3046
  if (!queueSlotHeld)
3009
3047
  return;
@@ -3473,6 +3511,55 @@ export async function startProxy(opts = {}) {
3473
3511
  return;
3474
3512
  }
3475
3513
  }
3514
+ // A named key's daily budget (dario#1318 follow-up): what the ledger has
3515
+ // counted for this key today against its caps, refused here in the
3516
+ // request's own wire shape before anything goes upstream. Checked at
3517
+ // request START against completed rows PLUS what is reserved for the
3518
+ // key's requests still in flight — each at an upper bound on its cost
3519
+ // (keys.ts requestBudgetReservation) until the ledger has the real number
3520
+ // — so a burst can complete for at most the cap plus one request.
3521
+ // `retry-after` is the UTC day boundary. Served responses carry the same
3522
+ // x-dario-budget-* headers so a client can watch its own headroom.
3523
+ let keyBudgetHeaders = {};
3524
+ if (requestAuth.key?.budget && ledger) {
3525
+ const inflightNow = keyInflight.get(requestAuth.key.name) ?? EMPTY_RESERVATION;
3526
+ const verdict = budgetVerdict(requestAuth.key.budget, ledger.consumerToday(requestAuth.key.name), Date.now(), inflightNow);
3527
+ keyBudgetHeaders = budgetHeaders(verdict, requestAuth.key.name);
3528
+ if (!verdict.over) {
3529
+ // Held at its upper bound until the response is in and the ledger has
3530
+ // it — bounded by what dario will SEND: the client's body plus the
3531
+ // template's prompt (passthrough adds nothing), and the max_tokens
3532
+ // that will go on the wire (the template pins its own default unless
3533
+ // --max-tokens=client; passthrough forwards the client's).
3534
+ const pb = parsedBody;
3535
+ const clientMax = pb ? (pb.max_tokens ?? pb.max_completion_tokens ?? pb.max_output_tokens) : undefined;
3536
+ const outboundMax = passthrough
3537
+ ? (typeof clientMax === 'number' ? clientMax : null)
3538
+ : resolveMaxTokens(opts.maxTokens, { max_tokens: clientMax });
3539
+ budgetReserved = requestBudgetReservation(typeof pb?.model === 'string' ? pb.model : '', body.length, outboundMax, costOfTokens, Date.now(), passthrough ? 0 : CC_TEMPLATE_PROMPT_BYTES);
3540
+ budgetInflightKey = requestAuth.key.name;
3541
+ keyInflight.set(budgetInflightKey, addReservation(inflightNow, budgetReserved));
3542
+ }
3543
+ if (verdict.over) {
3544
+ requestCount++;
3545
+ writeLogLine(logFileStream, {
3546
+ ts: new Date().toISOString(), req: requestCount,
3547
+ method: req.method ?? '', path: urlPath, status: 429, reject: `key-budget-${verdict.reason}`, consumer: requestAuth.key.name,
3548
+ });
3549
+ const inflightNote = verdict.inflight.count > 0
3550
+ ? (verdict.reason === 'usd' ? ` + $${verdict.inflight.usd.toFixed(2)} reserved for ${verdict.inflight.count} in flight` : ` + ${verdict.inflight.tokens} reserved for ${verdict.inflight.count} in flight`)
3551
+ : '';
3552
+ const cap = verdict.reason === 'usd'
3553
+ ? `$${verdict.budget.usdPerDay} API-equivalent per day (used $${verdict.usage.usd.toFixed(2)}${inflightNote})`
3554
+ : `${verdict.budget.tokensPerDay} tokens per day (used ${verdict.usage.tokens}${inflightNote})`;
3555
+ const msg = `key "${requestAuth.key.name}" is over its daily budget of ${cap}; resets at ${new Date(verdict.resetAt).toISOString()} (UTC midnight)`;
3556
+ res.writeHead(429, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin, 'retry-after': String(verdict.retryAfterSec), ...keyBudgetHeaders });
3557
+ res.end(JSON.stringify(isOpenAI
3558
+ ? { error: { message: msg, type: 'rate_limit_error', param: null, code: 'key_budget_exceeded' } }
3559
+ : { type: 'error', error: { type: 'rate_limit_error', message: msg } }));
3560
+ return;
3561
+ }
3562
+ }
3476
3563
  // Responses shape → Messages shape, once, before any routing peeks at
3477
3564
  // the body. The translated body is what a continuation re-issues too:
3478
3565
  // the loopback goes to /v1/messages, which is what this body now is.
@@ -5230,6 +5317,7 @@ export async function startProxy(opts = {}) {
5230
5317
  console.log(`[dario] #${requestCount} billing: headers absent (status=${upstream.status})`);
5231
5318
  }
5232
5319
  }
5320
+ Object.assign(responseHeaders, keyBudgetHeaders);
5233
5321
  Object.assign(responseHeaders, timingHeaders({
5234
5322
  queueMs, pacingMs, arrivedAt,
5235
5323
  fetchStartedAt: fetchStartedAt ?? Date.now(),
@@ -5603,6 +5691,13 @@ export async function startProxy(opts = {}) {
5603
5691
  if (onClientClose !== null)
5604
5692
  req.off('close', onClientClose);
5605
5693
  releaseQueueSlot();
5694
+ if (budgetInflightKey !== null) {
5695
+ const left = subtractReservation(keyInflight.get(budgetInflightKey) ?? budgetReserved, budgetReserved);
5696
+ if (left.count > 0)
5697
+ keyInflight.set(budgetInflightKey, left);
5698
+ else
5699
+ keyInflight.delete(budgetInflightKey);
5700
+ }
5606
5701
  }
5607
5702
  });
5608
5703
  server.on('error', async (err) => {
@@ -5650,6 +5745,11 @@ export async function startProxy(opts = {}) {
5650
5745
  // One-line template summary so users can tell at a glance whether they
5651
5746
  // booted on a fresh live capture or a stale bundled fallback.
5652
5747
  console.log(`[dario] template: ${describeTemplate(CC_TEMPLATE)}`);
5748
+ // A bundled definition the API refuses (dario#1376: advisor captured with an
5749
+ // empty schema) is kept as a known name and never advertised; say so once.
5750
+ if (CC_TOOL_DEFINITIONS_UNADVERTISABLE.size > 0) {
5751
+ console.log(`[dario] template: ${CC_TOOL_DEFINITIONS_UNADVERTISABLE.size} tool definition${CC_TOOL_DEFINITIONS_UNADVERTISABLE.size === 1 ? '' : 's'} without input_schema.type — known, never advertised: ${[...CC_TOOL_DEFINITIONS_UNADVERTISABLE].join(', ')}`);
5752
+ }
5653
5753
  // Drift check: compare captured CC version to the installed binary. If
5654
5754
  // they differ, force the background refresh to bypass TTL so the next
5655
5755
  // startup picks up the new capture. Drifted caches still serve the
package/docs/keys.md CHANGED
@@ -32,12 +32,57 @@ on its next request, no restart.
32
32
  | `--seat=<alias>` | The pool seat this key's traffic prefers. Taken whenever that seat is eligible right now; when it is parked on a 429, cooling down after an auth failure, or missing, the request routes like any other. A preference, not a pin: in-flight failover is unchanged, and the sticky binding follows the key so a conversation stays on the developer's own subscription. |
33
33
  | `--models=a,b,prefix*` | An allowlist. A request for any other model is refused with `403` — in the request's own wire shape, before anything goes upstream. Entries are exact ids or `prefix*`, case-insensitive. |
34
34
  | `--expires=30d` | Refused after this, like a revoked key. `12h`, `2w`, or an ISO date. |
35
+ | `--budget=$5/day` | A daily cap on the API-equivalent price of the key's traffic. See **Budgets**. |
36
+ | `--budget-tokens=2M/day` | A daily cap on tokens, every bucket counted. See **Budgets**. |
35
37
 
36
38
  `dario keys revoke <name>` refuses a key from now on and keeps it in the list;
37
39
  `dario keys rotate <name>` prints a new secret under the same name, seat,
38
40
  models and expiry, and the old secret stops at once; `dario keys remove
39
41
  <name>` forgets it.
40
42
 
43
+ ## Budgets
44
+
45
+ `dario keys create alice --budget=$5/day --budget-tokens=2M/day` — or
46
+ `dario keys budget alice --budget=$5/day` on an existing key, `--clear` to
47
+ remove it — caps what a key may use **per UTC day**:
48
+
49
+ - **`--budget=$5/day`**: the API-equivalent price of the key's traffic, the
50
+ same number `dario usage --by-key` prints — covered and metered rows both
51
+ count, because a budget is about what the key caused, not who paid.
52
+ - **`--budget-tokens=2M/day`** (`250k`, `2000000`): every token the key sent
53
+ or received, cache reads included.
54
+
55
+ The check runs at request **start** against the ledger's completed rows plus
56
+ what is **reserved** for the key's requests still in flight. A request is
57
+ reserved at an upper bound on what it can cost, from what dario will *send*:
58
+ the client's body plus the template's own system prompt and tool definitions,
59
+ at 3 bytes per token priced as cache-create (the highest input-side rate, so
60
+ any mix of input, cache-read and cache-create tokens — all prompt tokens —
61
+ costs no more), plus the `max_tokens` that will go on the wire (the template's
62
+ default, 64,000, unless `--max-tokens=client`; passthrough forwards the
63
+ client's; 8,192 when nothing is set) at the output rate — until its response is
64
+ in and the ledger has the real number. So the most a key can complete in a day
65
+ is the cap plus one request, whatever the size of a burst or of the requests
66
+ in it. The reservation is deliberately pessimistic: on a $5/day key in template
67
+ mode it admits roughly four requests at once until the first completes and its
68
+ real cost replaces the reservation. `--max-tokens=client` shrinks it. A request past the cap is refused
69
+ with `429`
70
+ in the request's own wire shape (`rate_limit_error`; OpenAI shape adds
71
+ `code: "key_budget_exceeded"`), a `retry-after` at the UTC day boundary, and
72
+ `reject: "key-budget-usd"` / `"key-budget-tokens"` on the log line. Served
73
+ responses carry the same `x-dario-budget-*` headers (`-key`, `-usd`,
74
+ `-used-usd`, `-tokens`, `-used-tokens`, `-inflight`, `-resets-at`) so a client can watch
75
+ its own headroom. `GET /analytics` lists every budgeted key under `budgets`
76
+ with today's use; `GET /metrics` exports `dario_key_budget_usd_per_day`,
77
+ `_used_usd`, `_tokens_per_day`, `_used_tokens` per key.
78
+
79
+ Budgets are **read from the ledger**. With the ledger off (`--no-ledger`,
80
+ `DARIO_LEDGER=0`) there is nothing to read; the proxy says so at startup
81
+ (`budgets on alice are NOT enforced`) and the key is served as if it had none.
82
+ Over HTTP: `budget_usd_per_day` / `budget_tokens_per_day` on
83
+ `POST /admin/keys`, and `POST /admin/keys/<name>/budget` with the same
84
+ fields (an empty body clears), audited as `key_budget`.
85
+
41
86
  ## Where it shows
42
87
 
43
88
  - **`GET /analytics`** — `perConsumer` (the rolling window) and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.9.2",
3
+ "version": "6.10.0",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -87,7 +87,7 @@
87
87
  "node": ">=18.0.0"
88
88
  },
89
89
  "devDependencies": {
90
- "@types/node": "^26.5.1",
90
+ "@types/node": "^26.6.1",
91
91
  "tsx": "^4.19.0",
92
92
  "typescript": "^5.7.0"
93
93
  }