@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 +1 -1
- package/dist/admin-api.d.ts +1 -1
- package/dist/admin-api.js +60 -5
- package/dist/cc-template.d.ts +22 -4
- package/dist/cc-template.js +41 -4
- package/dist/cli.js +65 -6
- package/dist/keys.d.ts +109 -0
- package/dist/keys.js +140 -1
- package/dist/ledger.d.ts +17 -0
- package/dist/ledger.js +29 -0
- package/dist/metrics.d.ts +7 -0
- package/dist/metrics.js +8 -0
- package/dist/proxy.js +104 -4
- package/docs/keys.md +45 -0
- package/package.json +2 -2
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
|
|
package/dist/admin-api.d.ts
CHANGED
|
@@ -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
|
-
|
|
248
|
-
const
|
|
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));
|
package/dist/cc-template.d.ts
CHANGED
|
@@ -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
|
package/dist/cc-template.js
CHANGED
|
@@ -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
|
-
|
|
2072
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
90
|
+
"@types/node": "^26.6.1",
|
|
91
91
|
"tsx": "^4.19.0",
|
|
92
92
|
"typescript": "^5.7.0"
|
|
93
93
|
}
|