@askalf/dario 6.9.3 → 6.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/admin-api.d.ts +8 -1
- package/dist/admin-api.js +61 -5
- package/dist/cc-template.d.ts +8 -0
- package/dist/cc-template.js +12 -0
- 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/pool.d.ts +50 -5
- package/dist/pool.js +138 -27
- package/dist/proxy.js +115 -9
- package/docs/keys.md +45 -0
- package/package.json +2 -2
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 { CC_TOOL_DEFINITIONS_UNADVERTISABLE, 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
|
-
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';
|
|
16
|
+
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, activeParkedBuckets, 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
|
|
@@ -2518,6 +2550,7 @@ export async function startProxy(opts = {}) {
|
|
|
2518
2550
|
requestCount: a.requestCount,
|
|
2519
2551
|
rejectedCount: a.rejectedCount,
|
|
2520
2552
|
lastRejectedAt: a.lastRejectedAt ?? null,
|
|
2553
|
+
parkedBuckets: activeParkedBuckets(a.rateLimit, snapNow),
|
|
2521
2554
|
organizationId: a.organizationId ?? null,
|
|
2522
2555
|
sharesWindowWith: peers.get(a.alias) ?? [],
|
|
2523
2556
|
sameAccountAs: peers.get(a.alias) ?? [],
|
|
@@ -2653,6 +2686,10 @@ export async function startProxy(opts = {}) {
|
|
|
2653
2686
|
// parked seat no longer reads as one that was never called.
|
|
2654
2687
|
rejectedCount: a.rejectedCount,
|
|
2655
2688
|
lastRejectedAt: a.lastRejectedAt ?? null,
|
|
2689
|
+
// Per-model buckets keeping their families off this seat while the
|
|
2690
|
+
// seat itself still serves (`7d_oi` → fable on a Pro seat whose
|
|
2691
|
+
// included overage is spent). Empty when nothing is parked that way.
|
|
2692
|
+
parkedBuckets: activeParkedBuckets(a.rateLimit, now),
|
|
2656
2693
|
// Which organization the token belongs to, who the token IS (OAuth
|
|
2657
2694
|
// account uuid, masked email), and which other seats are the same
|
|
2658
2695
|
// account — one subscription under several aliases (dario#1244,
|
|
@@ -2737,13 +2774,14 @@ export async function startProxy(opts = {}) {
|
|
|
2737
2774
|
// `queue` rides along the summary (dario#905): request-queue.ts always
|
|
2738
2775
|
// documented snapshot() as "exposed for /analytics", but it was never
|
|
2739
2776
|
// 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 }));
|
|
2777
|
+
res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), lifetime: ledger ? ledger.summary() : null, budgets: keyBudgetsSnapshot() }));
|
|
2741
2778
|
return;
|
|
2742
2779
|
}
|
|
2743
2780
|
// Prometheus text exposition of the same state (dario#1341). A view, not
|
|
2744
2781
|
// new collection: a scrape costs what GET /analytics costs. Same gate.
|
|
2745
2782
|
if (urlPath === '/metrics' && req.method === 'GET') {
|
|
2746
2783
|
const body = renderPrometheus({
|
|
2784
|
+
budgets: keyBudgetsSnapshot(),
|
|
2747
2785
|
summary: analytics.summary(),
|
|
2748
2786
|
queue: queue.snapshot(),
|
|
2749
2787
|
lifetime: ledger ? ledger.summary() : null,
|
|
@@ -3004,6 +3042,11 @@ export async function startProxy(opts = {}) {
|
|
|
3004
3042
|
// "overhead" never has to be guessed at (src/timing.ts).
|
|
3005
3043
|
let queueMs = 0;
|
|
3006
3044
|
let pacingMs = 0;
|
|
3045
|
+
// The key this request was admitted under with a budget and what was
|
|
3046
|
+
// reserved for it, for the release in the finally below; null when no
|
|
3047
|
+
// budget applied.
|
|
3048
|
+
let budgetInflightKey = null;
|
|
3049
|
+
let budgetReserved = EMPTY_RESERVATION;
|
|
3007
3050
|
const releaseQueueSlot = () => {
|
|
3008
3051
|
if (!queueSlotHeld)
|
|
3009
3052
|
return;
|
|
@@ -3058,6 +3101,11 @@ export async function startProxy(opts = {}) {
|
|
|
3058
3101
|
// before that point they remain at their initial values, which is
|
|
3059
3102
|
// also exactly what we want to log on early-failure paths.
|
|
3060
3103
|
let requestModel = '';
|
|
3104
|
+
// The Claude family the pool is asked to serve, known once the body's
|
|
3105
|
+
// model is read (before `selectPoolAccount()` runs): a seat parked on a
|
|
3106
|
+
// per-model bucket is off for that family only, so selection, the key's
|
|
3107
|
+
// preferred seat and the all-parked answer all ask with it.
|
|
3108
|
+
let requestFamily = null;
|
|
3061
3109
|
let detectedClientForLog;
|
|
3062
3110
|
let preserveToolsEffective = Boolean(opts.preserveTools);
|
|
3063
3111
|
// Per-request: did isGenuineCCClient recognise the caller as real Claude
|
|
@@ -3247,15 +3295,15 @@ export async function startProxy(opts = {}) {
|
|
|
3247
3295
|
// eligible right now, else the pool picks as usual. Failover
|
|
3248
3296
|
// mid-request is unchanged either way — a preference, not a pin.
|
|
3249
3297
|
const preferredSeat = requestAuth.key?.seat ? (pool.get(requestAuth.key.seat) ?? null) : null;
|
|
3250
|
-
keySeatTaken = preferredSeat !== null && isAccountEligible(preferredSeat, Date.now());
|
|
3251
|
-
poolAccount = keySeatTaken ? preferredSeat : pool.select();
|
|
3298
|
+
keySeatTaken = preferredSeat !== null && isAccountEligible(preferredSeat, Date.now(), requestFamily);
|
|
3299
|
+
poolAccount = keySeatTaken ? preferredSeat : pool.select(requestFamily);
|
|
3252
3300
|
if (poolAccount)
|
|
3253
3301
|
poolParkedAnnounced = false;
|
|
3254
3302
|
// Every seat parked inside a live window (dario#1244): cool the
|
|
3255
3303
|
// provider to the earliest reset so a fallback chain sees the Claude
|
|
3256
3304
|
// half as what it is, say so once, and — unless a fallback is armed —
|
|
3257
3305
|
// answer the client here instead of spending a probe that can only 429.
|
|
3258
|
-
const parkedUntil = poolAccount ? null : pool.parkedUntil();
|
|
3306
|
+
const parkedUntil = poolAccount ? null : pool.parkedUntil(Date.now(), requestFamily);
|
|
3259
3307
|
if (parkedUntil !== null) {
|
|
3260
3308
|
providerCooldowns.note('claude', parkedUntil - Date.now());
|
|
3261
3309
|
if (!poolParkedAnnounced) {
|
|
@@ -3473,6 +3521,55 @@ export async function startProxy(opts = {}) {
|
|
|
3473
3521
|
return;
|
|
3474
3522
|
}
|
|
3475
3523
|
}
|
|
3524
|
+
// A named key's daily budget (dario#1318 follow-up): what the ledger has
|
|
3525
|
+
// counted for this key today against its caps, refused here in the
|
|
3526
|
+
// request's own wire shape before anything goes upstream. Checked at
|
|
3527
|
+
// request START against completed rows PLUS what is reserved for the
|
|
3528
|
+
// key's requests still in flight — each at an upper bound on its cost
|
|
3529
|
+
// (keys.ts requestBudgetReservation) until the ledger has the real number
|
|
3530
|
+
// — so a burst can complete for at most the cap plus one request.
|
|
3531
|
+
// `retry-after` is the UTC day boundary. Served responses carry the same
|
|
3532
|
+
// x-dario-budget-* headers so a client can watch its own headroom.
|
|
3533
|
+
let keyBudgetHeaders = {};
|
|
3534
|
+
if (requestAuth.key?.budget && ledger) {
|
|
3535
|
+
const inflightNow = keyInflight.get(requestAuth.key.name) ?? EMPTY_RESERVATION;
|
|
3536
|
+
const verdict = budgetVerdict(requestAuth.key.budget, ledger.consumerToday(requestAuth.key.name), Date.now(), inflightNow);
|
|
3537
|
+
keyBudgetHeaders = budgetHeaders(verdict, requestAuth.key.name);
|
|
3538
|
+
if (!verdict.over) {
|
|
3539
|
+
// Held at its upper bound until the response is in and the ledger has
|
|
3540
|
+
// it — bounded by what dario will SEND: the client's body plus the
|
|
3541
|
+
// template's prompt (passthrough adds nothing), and the max_tokens
|
|
3542
|
+
// that will go on the wire (the template pins its own default unless
|
|
3543
|
+
// --max-tokens=client; passthrough forwards the client's).
|
|
3544
|
+
const pb = parsedBody;
|
|
3545
|
+
const clientMax = pb ? (pb.max_tokens ?? pb.max_completion_tokens ?? pb.max_output_tokens) : undefined;
|
|
3546
|
+
const outboundMax = passthrough
|
|
3547
|
+
? (typeof clientMax === 'number' ? clientMax : null)
|
|
3548
|
+
: resolveMaxTokens(opts.maxTokens, { max_tokens: clientMax });
|
|
3549
|
+
budgetReserved = requestBudgetReservation(typeof pb?.model === 'string' ? pb.model : '', body.length, outboundMax, costOfTokens, Date.now(), passthrough ? 0 : CC_TEMPLATE_PROMPT_BYTES);
|
|
3550
|
+
budgetInflightKey = requestAuth.key.name;
|
|
3551
|
+
keyInflight.set(budgetInflightKey, addReservation(inflightNow, budgetReserved));
|
|
3552
|
+
}
|
|
3553
|
+
if (verdict.over) {
|
|
3554
|
+
requestCount++;
|
|
3555
|
+
writeLogLine(logFileStream, {
|
|
3556
|
+
ts: new Date().toISOString(), req: requestCount,
|
|
3557
|
+
method: req.method ?? '', path: urlPath, status: 429, reject: `key-budget-${verdict.reason}`, consumer: requestAuth.key.name,
|
|
3558
|
+
});
|
|
3559
|
+
const inflightNote = verdict.inflight.count > 0
|
|
3560
|
+
? (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`)
|
|
3561
|
+
: '';
|
|
3562
|
+
const cap = verdict.reason === 'usd'
|
|
3563
|
+
? `$${verdict.budget.usdPerDay} API-equivalent per day (used $${verdict.usage.usd.toFixed(2)}${inflightNote})`
|
|
3564
|
+
: `${verdict.budget.tokensPerDay} tokens per day (used ${verdict.usage.tokens}${inflightNote})`;
|
|
3565
|
+
const msg = `key "${requestAuth.key.name}" is over its daily budget of ${cap}; resets at ${new Date(verdict.resetAt).toISOString()} (UTC midnight)`;
|
|
3566
|
+
res.writeHead(429, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin, 'retry-after': String(verdict.retryAfterSec), ...keyBudgetHeaders });
|
|
3567
|
+
res.end(JSON.stringify(isOpenAI
|
|
3568
|
+
? { error: { message: msg, type: 'rate_limit_error', param: null, code: 'key_budget_exceeded' } }
|
|
3569
|
+
: { type: 'error', error: { type: 'rate_limit_error', message: msg } }));
|
|
3570
|
+
return;
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3476
3573
|
// Responses shape → Messages shape, once, before any routing peeks at
|
|
3477
3574
|
// the body. The translated body is what a continuation re-issues too:
|
|
3478
3575
|
// the loopback goes to /v1/messages, which is what this body now is.
|
|
@@ -3684,6 +3781,7 @@ export async function startProxy(opts = {}) {
|
|
|
3684
3781
|
// Reassignable: the codex effort-suffix strip below rewrites it, and
|
|
3685
3782
|
// every routing decision after that point must see the stripped name.
|
|
3686
3783
|
let rawModel = (peek.model || '').toString();
|
|
3784
|
+
requestFamily = modelFamily(rawModel);
|
|
3687
3785
|
// Credentials are re-read per request (not cached at startup) because
|
|
3688
3786
|
// a refresh rotates them on disk; getFreshCodexAccount refreshes when
|
|
3689
3787
|
// inside the expiry buffer, collapsing concurrent refreshes per alias.
|
|
@@ -4130,7 +4228,7 @@ export async function startProxy(opts = {}) {
|
|
|
4130
4228
|
if (!upstreamApiKey && !poolAccount) {
|
|
4131
4229
|
// A fallback was armed but nothing could serve, and the pool itself is
|
|
4132
4230
|
// parked: the exact reset beats a cool-down estimate (dario#1244).
|
|
4133
|
-
const parkedNow = pool.parkedUntil();
|
|
4231
|
+
const parkedNow = pool.parkedUntil(Date.now(), requestFamily);
|
|
4134
4232
|
if (parkedNow !== null) {
|
|
4135
4233
|
writePoolParked(parkedNow);
|
|
4136
4234
|
return;
|
|
@@ -5230,6 +5328,7 @@ export async function startProxy(opts = {}) {
|
|
|
5230
5328
|
console.log(`[dario] #${requestCount} billing: headers absent (status=${upstream.status})`);
|
|
5231
5329
|
}
|
|
5232
5330
|
}
|
|
5331
|
+
Object.assign(responseHeaders, keyBudgetHeaders);
|
|
5233
5332
|
Object.assign(responseHeaders, timingHeaders({
|
|
5234
5333
|
queueMs, pacingMs, arrivedAt,
|
|
5235
5334
|
fetchStartedAt: fetchStartedAt ?? Date.now(),
|
|
@@ -5603,6 +5702,13 @@ export async function startProxy(opts = {}) {
|
|
|
5603
5702
|
if (onClientClose !== null)
|
|
5604
5703
|
req.off('close', onClientClose);
|
|
5605
5704
|
releaseQueueSlot();
|
|
5705
|
+
if (budgetInflightKey !== null) {
|
|
5706
|
+
const left = subtractReservation(keyInflight.get(budgetInflightKey) ?? budgetReserved, budgetReserved);
|
|
5707
|
+
if (left.count > 0)
|
|
5708
|
+
keyInflight.set(budgetInflightKey, left);
|
|
5709
|
+
else
|
|
5710
|
+
keyInflight.delete(budgetInflightKey);
|
|
5711
|
+
}
|
|
5606
5712
|
}
|
|
5607
5713
|
});
|
|
5608
5714
|
server.on('error', async (err) => {
|
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.1",
|
|
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
|
}
|