@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/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.9.3",
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.5.1",
90
+ "@types/node": "^26.6.1",
91
91
  "tsx": "^4.19.0",
92
92
  "typescript": "^5.7.0"
93
93
  }