@askalf/dario 6.7.1 → 6.8.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/dist/proxy.js CHANGED
@@ -12,11 +12,12 @@ import { darioVersion } from './version.js';
12
12
  import { 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 { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
15
- import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail } from './pool.js';
15
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail, isAccountEligible } from './pool.js';
16
16
  import { backfillIdentity } from './accounts.js';
17
17
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
18
18
  import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
19
19
  import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
20
+ import { KeyStore, keyAllowsModel, resolveKeysPath, looksLikeNamedKey } from './keys.js';
20
21
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
21
22
  import { notify as osNotify } from './notify.js';
22
23
  import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
@@ -1944,6 +1945,18 @@ export async function startProxy(opts = {}) {
1944
1945
  // Optional proxy authentication — pre-encode key buffer for performance
1945
1946
  const apiKey = process.env.DARIO_API_KEY;
1946
1947
  const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
1948
+ // Named keys (dario#1318): one credential per developer, hashes on disk,
1949
+ // re-read when the file moves. Attribution and per-key limits ride on the
1950
+ // match; the root DARIO_API_KEY keeps working beside them.
1951
+ const keysOn = opts.keys !== false && process.env['DARIO_KEYS'] !== '0';
1952
+ const keyStore = keysOn ? new KeyStore(opts.keysPath ?? resolveKeysPath()) : null;
1953
+ if (keyStore) {
1954
+ keyStore.load();
1955
+ if (keyStore.error)
1956
+ console.error(`[dario] keys: ${keyStore.path} is unreadable (${keyStore.error}) — named keys are off until it is fixed`);
1957
+ else if (keyStore.size() > 0)
1958
+ console.log(`[dario] keys: ${keyStore.size()} named key${keyStore.size() === 1 ? '' : 's'} from ${keyStore.path}`);
1959
+ }
1947
1960
  // Admin API (#599) — opt-in headless account management at /admin/*. Off
1948
1961
  // unless DARIO_ADMIN=1. Auth is ALWAYS required (even on loopback) because
1949
1962
  // these endpoints add/remove OAuth accounts: the admin token is
@@ -2046,8 +2059,26 @@ export async function startProxy(opts = {}) {
2046
2059
  const ERR_UNAUTH = JSON.stringify({ error: 'Unauthorized', message: 'Invalid or missing API key' });
2047
2060
  const ERR_FORBIDDEN = JSON.stringify({ error: 'Forbidden', message: 'Path not allowed. Supported paths: POST /v1/messages, POST /v1/messages/count_tokens, POST /v1/chat/completions, GET /v1/models' });
2048
2061
  const ERR_METHOD = JSON.stringify({ error: 'Method not allowed' });
2049
- function checkAuth(req) {
2050
- return authenticateRequest(req.headers, apiKeyBuf);
2062
+ /**
2063
+ * Who is asking. A named key wins when it matches, and is then the
2064
+ * request's consumer; otherwise the root key decides as it always has,
2065
+ * including the open-on-loopback default when no key is configured at all.
2066
+ * A revoked or expired named key is indistinguishable from a wrong one.
2067
+ */
2068
+ function resolveRequestAuth(req) {
2069
+ const provided = req.headers['x-api-key']
2070
+ || req.headers.authorization?.replace(/^Bearer\s+/i, '');
2071
+ const key = keyStore ? keyStore.match(provided) : null;
2072
+ if (key) {
2073
+ keyStore.touch(key);
2074
+ return { ok: true, key };
2075
+ }
2076
+ // A `dk_` value that matched nothing is refused even on a proxy with no
2077
+ // root key (where an absent credential would pass): the client presented
2078
+ // a dario credential, and a revoked one must mean refused, not anonymous.
2079
+ if (keyStore && !apiKeyBuf && looksLikeNamedKey(provided))
2080
+ return { ok: false, key: null };
2081
+ return { ok: authenticateRequest(req.headers, apiKeyBuf), key: null };
2051
2082
  }
2052
2083
  /**
2053
2084
  * A ChatGPT seat declined. Cool the SEAT, and the provider only once every
@@ -2355,6 +2386,10 @@ export async function startProxy(opts = {}) {
2355
2386
  if (adminEnabled && urlPath.startsWith('/admin/')) {
2356
2387
  const handled = await handleAdminRequest(req, res, urlPath, {
2357
2388
  adminTokenBuf,
2389
+ // Named keys over HTTP (dario#1318): the same file `dario keys` edits,
2390
+ // through the store the live proxy authenticates from, so a key made
2391
+ // here works on the next request.
2392
+ keys: keyStore,
2358
2393
  onAccountsChanged: async () => {
2359
2394
  // Hot-reload the live pool from disk so accounts added / removed via
2360
2395
  // the admin API take effect immediately — no proxy restart (#599).
@@ -2418,7 +2453,8 @@ export async function startProxy(opts = {}) {
2418
2453
  // line when --log-file / DARIO_LOG_FILE is set.
2419
2454
  audit: (e) => {
2420
2455
  const detail = e.detail ? ` detail=${e.detail}` : '';
2421
- const line = `[dario] admin-audit: ${e.action} alias=${e.alias ?? '-'} ok=${e.ok} status=${e.status} from=${e.remote ?? '-'}${detail}`;
2456
+ const target = e.key ? `key=${e.key}` : `alias=${e.alias ?? '-'}`;
2457
+ const line = `[dario] admin-audit: ${e.action} ${target} ok=${e.ok} status=${e.status} from=${e.remote ?? '-'}${detail}`;
2422
2458
  if (e.ok)
2423
2459
  console.log(line);
2424
2460
  else
@@ -2431,6 +2467,7 @@ export async function startProxy(opts = {}) {
2431
2467
  status: e.status,
2432
2468
  event: `admin.${e.action}`,
2433
2469
  account: e.alias,
2470
+ key: e.key,
2434
2471
  reject: e.ok ? undefined : 'admin-auth',
2435
2472
  });
2436
2473
  },
@@ -2446,12 +2483,16 @@ export async function startProxy(opts = {}) {
2446
2483
  if (handled)
2447
2484
  return;
2448
2485
  }
2449
- if (!checkAuth(req)) {
2486
+ const requestAuth = resolveRequestAuth(req);
2487
+ if (!requestAuth.ok) {
2450
2488
  if (verbose) {
2451
2489
  // Silent auth rejects are hard to diagnose when a client's config
2452
2490
  // doesn't quite match what dario expects (dario#97). Emit a
2453
- // one-line reject log under -v so operators see auth misfires.
2454
- console.error(`[dario] #${requestCount} 401 rejected (DARIO_API_KEY mismatch): ${describeAuthReject(req.headers)}`);
2491
+ // one-line reject log under -v so operators see auth misfires. A
2492
+ // `dk_` value that did not match is a named key that is unknown,
2493
+ // revoked or expired — the three are one case on purpose.
2494
+ const provided = req.headers['x-api-key'] || req.headers.authorization?.replace(/^Bearer\s+/i, '');
2495
+ console.error(`[dario] #${requestCount} 401 rejected (${looksLikeNamedKey(provided) ? 'named key unknown, revoked or expired' : 'DARIO_API_KEY mismatch'}): ${describeAuthReject(req.headers)}`);
2455
2496
  }
2456
2497
  writeLogLine(logFileStream, {
2457
2498
  ts: new Date().toISOString(), req: requestCount,
@@ -2810,7 +2851,10 @@ export async function startProxy(opts = {}) {
2810
2851
  // attribution. Without one, attribution falls back to a hash of the
2811
2852
  // body's user id once the body is parsed; the cap needs the name before
2812
2853
  // the slot is taken, so only the header gates.
2813
- const consumerFromHeaders = consumerFromHeader(req.headers[CONSUMER_HEADER]);
2854
+ // A named key (dario#1318) is the consumer: the credential says who this
2855
+ // is, and a header cannot overrule it. Without one, the header names the
2856
+ // consumer as before.
2857
+ const consumerFromHeaders = requestAuth.key?.name ?? consumerFromHeader(req.headers[CONSUMER_HEADER]);
2814
2858
  let consumer = consumerFromHeaders;
2815
2859
  // Proxy to Anthropic (with concurrency control). The bounded queue
2816
2860
  // replaces the v3.30.x-and-earlier unbounded semaphore — dario#80. A
@@ -3041,6 +3085,9 @@ export async function startProxy(opts = {}) {
3041
3085
  }
3042
3086
  if (pinnedAccount && verbose)
3043
3087
  console.log(`[dario] seat pin → ${pinnedAccount.alias} (no failover)`);
3088
+ // True when a named key's preferred seat was taken for this request
3089
+ // (dario#1318): the sticky binding then follows the key, not the pool.
3090
+ let keySeatTaken = false;
3044
3091
  const selectPoolAccount = () => {
3045
3092
  if (upstreamApiKey) {
3046
3093
  // Per-token API-key mode: no OAuth, no pool selection. `poolAccount`
@@ -3055,7 +3102,12 @@ export async function startProxy(opts = {}) {
3055
3102
  accessToken = pinnedAccount.accessToken;
3056
3103
  return true;
3057
3104
  }
3058
- poolAccount = pool.select();
3105
+ // A named key's preferred seat (dario#1318): taken when that seat is
3106
+ // eligible right now, else the pool picks as usual. Failover
3107
+ // mid-request is unchanged either way — a preference, not a pin.
3108
+ const preferredSeat = requestAuth.key?.seat ? (pool.get(requestAuth.key.seat) ?? null) : null;
3109
+ keySeatTaken = preferredSeat !== null && isAccountEligible(preferredSeat, Date.now());
3110
+ poolAccount = keySeatTaken ? preferredSeat : pool.select();
3059
3111
  if (poolAccount)
3060
3112
  poolParkedAnnounced = false;
3061
3113
  // Every seat parked inside a live window (dario#1244): cool the
@@ -3262,6 +3314,24 @@ export async function startProxy(opts = {}) {
3262
3314
  return;
3263
3315
  }
3264
3316
  }
3317
+ // A named key's model allowlist (dario#1318): refused here, in the
3318
+ // request's own wire shape, before anything goes upstream.
3319
+ if (requestAuth.key?.models?.length && parsedBody) {
3320
+ const wanted = typeof parsedBody.model === 'string' ? parsedBody.model : '';
3321
+ if (!keyAllowsModel(requestAuth.key, wanted)) {
3322
+ requestCount++;
3323
+ writeLogLine(logFileStream, {
3324
+ ts: new Date().toISOString(), req: requestCount,
3325
+ method: req.method ?? '', path: urlPath, status: 403, reject: 'key-model',
3326
+ });
3327
+ const msg = `model "${wanted}" is not allowed for key "${requestAuth.key.name}" (allowed: ${requestAuth.key.models.join(', ')})`;
3328
+ res.writeHead(403, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
3329
+ res.end(JSON.stringify(isOpenAI
3330
+ ? { error: { message: msg, type: 'permission_error', param: 'model', code: 'model_not_allowed' } }
3331
+ : { type: 'error', error: { type: 'permission_error', message: msg } }));
3332
+ return;
3333
+ }
3334
+ }
3265
3335
  // Responses shape → Messages shape, once, before any routing peeks at
3266
3336
  // the body. The translated body is what a continuation re-issues too:
3267
3337
  // the loopback goes to /v1/messages, which is what this body now is.
@@ -4025,7 +4095,14 @@ export async function startProxy(opts = {}) {
4025
4095
  // that already has the Anthropic prompt cache warmed for it.
4026
4096
  // Rotating off mid-session costs cache-create on every turn.
4027
4097
  stickyKey = computeStickyKey(userMsg);
4028
- if (stickyKey && !pinnedAccount) {
4098
+ if (stickyKey && keySeatTaken && poolAccount) {
4099
+ // A named key's seat is the binding (dario#1318): the
4100
+ // developer's conversation stays on their own subscription, and
4101
+ // a 429 failover below rebinds it exactly as any other.
4102
+ pool.rebindSticky(stickyKey, poolAccount.alias);
4103
+ poolSync?.bindSticky(stickyKey, poolAccount.alias);
4104
+ }
4105
+ else if (stickyKey && !pinnedAccount) {
4029
4106
  // Shared state (pool-sync.ts): a conversation a peer instance
4030
4107
  // already bound lands on the same seat here, so its prompt
4031
4108
  // cache is read rather than rewritten. Only consulted when this
@@ -5582,6 +5659,7 @@ export async function startProxy(opts = {}) {
5582
5659
  // Flush tokens first (best-effort, bounded), then close the server. The
5583
5660
  // flush is fire-and-forget under the same 5s force-exit guard below so a
5584
5661
  // hung fsync can't wedge shutdown.
5662
+ keyStore?.close();
5585
5663
  void Promise.all([flushPoolTokens(), ledger?.close()]).finally(() => {
5586
5664
  server.close(() => process.exit(0));
5587
5665
  });
package/docs/admin-api.md CHANGED
@@ -93,6 +93,16 @@ All endpoints accept the token as `authorization: Bearer <token>` or
93
93
  | `POST /admin/login/complete` (batch) | `{ "items": [{ "alias", "code" }, ...] }` | `{ results: [...], count, truncated? }` |
94
94
  | `GET /admin/accounts` | — | `{ accounts: [...], count }` |
95
95
  | `DELETE /admin/accounts/<alias>` | — | `{ alias, removed }` (`404` if no such alias) |
96
+ | `GET /admin/keys` | — | `{ keys: [...], count, path }` — every named key, hashes excluded (v6.8) |
97
+ | `POST /admin/keys` | `{ "name": string, "seat"?: string, "models"?: string[] \| "a,b", "expires"?: "30d" \| ISO }` | `201 { key, secret }` — the secret once; `409` if the name exists |
98
+ | `POST /admin/keys/<name>/rotate` | — | `{ key, secret }` — a new secret, the old one dead at once (`404` if unknown) |
99
+ | `DELETE /admin/keys/<name>` | — | `{ name, revoked }` — refused from now on, kept in the list (`404` if unknown) |
100
+
101
+ The three `/admin/keys` mutations edit the same `~/.dario/keys.json` that
102
+ `dario keys` does, through the store the running proxy authenticates from,
103
+ so a key minted here works on the next request. With named keys off
104
+ (`--no-keys` / `DARIO_KEYS=0`) the routes answer `404`. What a key carries
105
+ and where it shows: [keys.md](./keys.md).
96
106
 
97
107
  `GET /admin/accounts` is the monitoring surface: each entry carries the
98
108
  persisted metadata (`alias`, `scopes`, `expires_in_ms`, the grant-age fields)
@@ -213,12 +223,13 @@ container being *up* (TCP/HTTP response), not *healthy*.
213
223
 
214
224
  ## Audit trail
215
225
 
216
- Every mutation (`login_start`, `login_complete`, `account_remove`) and every
217
- auth reject or throttle is logged with the action, target alias, outcome, HTTP
218
- status, and client address to the console always (so `docker logs` /
219
- journald has the trail with zero setup), and as a structured
220
- `event: "admin.<action>"` line when `--log-file` / `DARIO_LOG_FILE` is set.
221
- Secrets never reach the audit sink.
226
+ Every mutation (`login_start`, `login_complete`, `account_remove`,
227
+ `key_create`, `key_rotate`, `key_revoke`) and every auth reject or throttle
228
+ is logged with the action, target alias or key name, outcome, HTTP status,
229
+ and client address — to the console always (so `docker logs` / journald has
230
+ the trail with zero setup), and as a structured `event: "admin.<action>"`
231
+ line when `--log-file` / `DARIO_LOG_FILE` is set. Secrets never reach the
232
+ audit sink; a key event carries the key's name, never its secret.
222
233
 
223
234
  ## Rate limiting
224
235
 
@@ -228,12 +239,12 @@ mutations and **to failed auth attempts** separately:
228
239
 
229
240
  - **Failed auth**: 10 burst, then 1 per 2s — a wrong-token flood is slowed,
230
241
  not answered at full speed.
231
- - **Mutations** (`login/start`, `login/complete`, account removal): 30 burst,
232
- then 1 per 1s.
242
+ - **Mutations** (`login/start`, `login/complete`, account removal, key
243
+ create / rotate / revoke): 30 burst, then 1 per 1s.
233
244
 
234
245
  Over the limit returns `429` with a `Retry-After` header, and the throttle
235
- itself is audited (`rate_limited`). Reads (`GET /admin/accounts`) and
236
- successful auth are never throttled. `DARIO_ADMIN_RATE_LIMIT=off` disables
246
+ itself is audited (`rate_limited`). Reads (`GET /admin/accounts`,
247
+ `GET /admin/keys`) and successful auth are never throttled. `DARIO_ADMIN_RATE_LIMIT=off` disables
237
248
  both buckets; the defaults are generous for a human plus scripts and only
238
249
  bite runaway callers.
239
250
 
@@ -91,6 +91,20 @@ port would write. `--card[=file.svg]` renders the headline as a 640×320 SVG
91
91
  fetch, so it looks the same in a README and a screenshot. `--json` is the raw
92
92
  `/analytics` payload, `lifetime` included.
93
93
 
94
+ `--by-key` (6.8) splits the number per consumer — a [named key](./keys.md),
95
+ an `x-dario-consumer` header, or the `u_…` hash of a client's user id,
96
+ whichever named the request. The file keeps a second table for it,
97
+ `consumers`, with the same per-day, per-model, per-bucket rows; only requests
98
+ that named a consumer land there, so the headline and the split can differ
99
+ by the anonymous traffic.
100
+
101
+ ```
102
+ By key (3 consumers; API-equivalent, lifetime · today · 7d · 30d):
103
+ alice $301.44 · $31.10 · $301.44 · $301.44 900 reqs, Opus 5, Sonnet 5
104
+ bob $86.68 · $12.00 · $86.68 · $86.68 304 reqs, Sonnet 5
105
+ ci $24.75 · $5.10 · $24.75 · $24.75 311 reqs, gpt-5.6-terra
106
+ ```
107
+
94
108
  `GET /analytics` → `lifetime`:
95
109
 
96
110
  ```json
@@ -104,7 +118,8 @@ fetch, so it looks the same in a README and a screenshot. `--json` is the raw
104
118
  "tokens": { "input": 1204000, "output": 388000, "cacheRead": 91200000, "cacheCreate": 4100000 },
105
119
  "perProvider": { "anthropic": { "requests": 1204, "apiEquivalentCost": 388.12 }, "openai": { "requests": 311, "apiEquivalentCost": 24.75 } },
106
120
  "perModel": { "claude-opus-5": { "provider": "anthropic", "requests": 900, "apiEquivalentCost": 301.44, "meteredCost": 1.1, "...": "token totals" } },
107
- "recent": { "today": 48.2, "last7d": 412.87, "last30d": 412.87 }
121
+ "recent": { "today": 48.2, "last7d": 412.87, "last30d": 412.87 },
122
+ "perConsumer": { "alice": { "requests": 900, "apiEquivalentCost": 301.44, "meteredCost": 0, "recent": { "...": "same three windows" }, "lastDay": "2026-09-13", "models": ["claude-opus-5", "claude-sonnet-5"] } }
108
123
  }
109
124
  ```
110
125
 
package/docs/commands.md CHANGED
@@ -9,7 +9,8 @@ This page is the per-flag reference. For environment variables grouped by task
9
9
  | `dario login [--manual]` | Log in to the Claude backend. Detects CC credentials or runs its own OAuth flow. `--manual` (v3.20) mirrors CC's code-paste flow for SSH / container setups without a browser. |
10
10
  | `dario proxy` | Start the local API proxy on port 3456 |
11
11
  | `dario doctor [--probe] [--auth-check] [--json] [--bun-bootstrap]` | Aggregated health report — dario / Node / runtime-TLS / CC binary + compat / template + drift / per-request overhead / OAuth / pool + pool routing (next account in rotation when 2+ loaded) / backends / sub-agent. `--probe` (v3.31.7) hits the live `claude.ai/oauth/authorize` endpoint and surfaces the verdict, so scope-policy drift is catchable from a user's machine (not just CI). `--auth-check` (v3.31.9) opens a one-shot `x-api-key` listener and classifies whatever a client actually sends (match / mismatch / no-auth / timeout), with only redacted previews in output. `--json` (v3.31.8) emits structured output for deepdive's health probes and CI scrapers. `--bun-bootstrap` runs the canonical bun.sh installer when the runtime/TLS check is warning that Bun isn't on PATH. |
12
- | `dario usage [--port=N] [--json]` | Burn-rate summary of the running proxy's traffic over the last 60 minutes: requests, input/output tokens, avg latency, error rate, subscription % vs. extra-usage, estimated API-equivalent cost, plus per-account breakdown when pool mode is active. Hits `/analytics` on the local proxy. When the proxy isn't reachable, prints a hint pointing at `dario doctor --usage` (the one-off rate-limit probe). `--json` emits the raw `/analytics` payload for status bars / CI dashboards. Also exposed as the `usage` tool in `dario mcp`. |
12
+ | `dario usage [--port=N] [--json] [--by-key]` | Burn-rate summary of the running proxy's traffic over the last 60 minutes: requests, input/output tokens, avg latency, error rate, subscription % vs. extra-usage, estimated API-equivalent cost, plus per-account breakdown when pool mode is active. Hits `/analytics` on the local proxy (presenting `DARIO_API_KEY` when the environment has it). When the proxy isn't reachable, prints a hint pointing at `dario doctor --usage` (the one-off rate-limit probe). `--json` emits the raw `/analytics` payload for status bars / CI dashboards. `--by-key` (v6.8) splits the lifetime API-equivalent number per consumer — named key, `x-dario-consumer` header, or hashed user id. Also exposed as the `usage` tool in `dario mcp`. |
13
+ | `dario keys create <name> [--seat=<alias>] [--models=a,b,prefix*] [--expires=30d]` / `list [--json]` / `revoke <name>` / `rotate <name>` / `remove <name>` | Named keys (v6.8): one credential per developer on a shared dario, attributed by key in `/analytics`, the ledger and the log. `create` prints the secret once and stores a hash in `~/.dario/keys.json`; `--seat` prefers a pool seat while it has headroom; `--models` refuses any other model with `403`; `--expires` refuses the key after `30d` / `12h` / `2w` / an ISO date. The running proxy sees a change on its next request. `--keys-path=<file>` on any of them names another file. See [Named keys](./keys.md). |
13
14
  | `dario config [--json]` | Prints the effective dario configuration with credentials redacted. Complementary to `doctor` — doctor answers *is it working?*, config answers *what IS it?* (v3.31.10) |
14
15
  | `dario upgrade` | Safe wrapper over `npm install -g @askalf/dario@latest` — probes npm for the `@latest` version first (3s timeout, 60s cache), refuses to run if already on latest, fails with a clear hint if npm is missing. (v3.31.10) |
15
16
  | `dario status` | Show Claude backend OAuth token health and expiry |
@@ -38,6 +39,7 @@ This page is the per-flag reference. For environment variables grouped by task
38
39
  | `--host=<addr>` / `DARIO_HOST` | Bind address. Use `0.0.0.0` for LAN, or a specific IP (e.g. a Tailscale interface). When non-loopback, also set `DARIO_API_KEY`. | `127.0.0.1` |
39
40
  | `--verbose` / `-v` | Log every request (one line per request — method + path + billing bucket) | off |
40
41
  | `--verbose=2` / `-vv` / `DARIO_LOG_BODIES=1` | Also dump the outbound request body (redacted: bearer tokens, `sk-ant-*` keys, JWTs stripped; capped at 8KB). For wire-level client-compat debugging. | off |
42
+ | `--no-keys` / `DARIO_KEYS=0`; `--keys-path=<file>` / `DARIO_KEYS_PATH` | Ignore named keys (`~/.dario/keys.json`) so only `DARIO_API_KEY` authenticates; or move the file. See [Named keys](./keys.md). (v6.8) | on, `~/.dario/keys.json` |
41
43
  | `--log-file=<path>` / `DARIO_LOG_FILE` | Append one JSON-ND record per completed request to PATH. Useful for backgrounded proxies where stdout is unobserved (where `--verbose` can't help). Field set: `ts`, `req`, `method`, `path`, `model`, `status`, `latency_ms`, `in_tokens`, `out_tokens`, `cache_read`, `cache_create`, `claim`, `bucket`, `account`, `client`, `preserve_tools`, `stream`, plus `reject` / `error` on failure paths. Secrets scrubbed via the same redactor that `--verbose-bodies` uses; no request bodies. | off |
42
44
  | `--pool-fallback=<models>` / `DARIO_POOL_FALLBACK` / config `poolFallback.model` | Strictly opt-in. When every pool seat is drained or in auth cool-down (at selection, or mid-flight on a 429 with no peer left), serve the request as `<model>` from whichever provider can, instead of surfacing the 429/503. Accepts a **chain** — `gpt-5.6-sol,claude-sonnet-5` — read left to right, each provider taking the first entry it can serve. A Codex/ChatGPT subscription that lists the model is preferred (no per-token cost) and works on **both** wire shapes; otherwise a configured openai-compat backend, which is still OpenAI-path only (no Messages translation on that route). A chain also makes failover **symmetric**: a rate-limited or failing subscription hands the request back to the Claude pool. Only a 429/5xx fails over — a 400 surfaces, since a bad request would just reproduce itself elsewhere. Every substituted response carries `x-dario-pool-fallback: <model>` — never silent. Needs a Codex account (`dario add altman`) or a backend (`dario backend add …`); `dario doctor` reports it as INERT with neither. Empty pool still 503s (setup error, not traffic to re-bill). Empty flag value disables, overriding env + config. See [Pool-exhausted fallback](./multi-account-pool.md#pool-exhausted-fallback). | off |
43
45
  | `--passthrough-betas=<csv>` / `DARIO_PASSTHROUGH_BETAS` | Beta flags ALWAYS forwarded upstream regardless of CC's captured set or the client's `anthropic-beta` header. Bypasses the billable-beta filter (so `extended-cache-ttl-*` survives if you opt in). Per-account rejection cache still applies — a pinned flag the upstream 400's gets dropped on retry rather than re-sent forever. Use when you know a beta works on your account but isn't in the captured template, or when client traffic should be force-augmented. Empty flag value (`--passthrough-betas=`) clears the env-default. | off |
@@ -43,6 +43,13 @@ Halts the proxy when an upstream response reports `representative-claim: overage
43
43
  | `DARIO_QUEUE_TIMEOUT_MS` | `--queue-timeout=MS` | `60000` | a queued request waiting longer gets 504 `queue-timeout` |
44
44
  | `DARIO_MAX_CONCURRENT_PER_CONSUMER` | `--max-concurrent-per-consumer=N` | `0` (off) | in-flight ceiling per consumer, keyed by the `x-dario-consumer` request header; a consumer at the cap waits in the queue while everyone else keeps flowing. See [Consumers](./multi-account-pool.md#consumers-who-a-request-is-for) |
45
45
 
46
+ ## Named keys
47
+
48
+ | Variable | Flag | Default | Notes |
49
+ |---|---|---|---|
50
+ | `DARIO_KEYS` | `--no-keys` | on | `0` ignores `~/.dario/keys.json`: only `DARIO_API_KEY` authenticates. See [Named keys](./keys.md) |
51
+ | `DARIO_KEYS_PATH` | `--keys-path=<file>` | `~/.dario/keys.json` | where the key hashes live; `dario keys` and the running proxy read the same file |
52
+
46
53
  ## Multi-instance
47
54
 
48
55
  | Variable | Flag | Default | Notes |
package/docs/keys.md ADDED
@@ -0,0 +1,112 @@
1
+ # Named keys — one credential per developer
2
+
3
+ A team runs one dario for several people. `DARIO_API_KEY` is one secret for
4
+ all of them, so nothing says whose traffic is whose except an
5
+ `x-dario-consumer` header any client can set to anything. A named key ties
6
+ attribution to the credential: the request authenticated with alice's key
7
+ *is* alice's — in `/analytics`, in the ledger, on every log line — and a key
8
+ can carry two things a header never could: a preferred seat and a model
9
+ allowlist.
10
+
11
+ ```bash
12
+ dario keys create alice
13
+ # Key "alice" created (id 3f1c9a2b).
14
+ #
15
+ # dk_7c0e… ← shown once; dario keeps only a hash
16
+ #
17
+ dario keys create bob --seat=bobs-max --models=claude-sonnet-5,claude-haiku*
18
+ dario keys create ci --expires=30d
19
+ dario keys list
20
+ ```
21
+
22
+ Give each person their key as the API key of whatever they point at dario
23
+ (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, a `Bearer` header — both wire shapes,
24
+ both headers). Nothing else changes: the root `DARIO_API_KEY` keeps working
25
+ beside the named keys, and a running proxy sees a new, rotated or revoked key
26
+ on its next request, no restart.
27
+
28
+ ## What a key carries
29
+
30
+ | Option | Effect |
31
+ |---|---|
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
+ | `--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
+ | `--expires=30d` | Refused after this, like a revoked key. `12h`, `2w`, or an ISO date. |
35
+
36
+ `dario keys revoke <name>` refuses a key from now on and keeps it in the list;
37
+ `dario keys rotate <name>` prints a new secret under the same name, seat,
38
+ models and expiry, and the old secret stops at once; `dario keys remove
39
+ <name>` forgets it.
40
+
41
+ ## Where it shows
42
+
43
+ - **`GET /analytics`** — `perConsumer` (the rolling window) and
44
+ `lifetime.perConsumer` (the ledger) are keyed by the key's name. A request
45
+ that carries a named key *and* an `x-dario-consumer` header is attributed
46
+ to the key; the header cannot overrule a credential.
47
+ - **`dario usage --by-key`** — the lifetime API-equivalent number split per
48
+ consumer: named keys, header names, and the `u_…` hash of a client's user
49
+ id, whichever named the request.
50
+ - **The log file** — `consumer` on every request line; `reject: "key-model"`
51
+ on a model refusal; `event: "admin.key_create|key_rotate|key_revoke"` with
52
+ `key: <name>` when the admin API changed something.
53
+ - **`--max-concurrent-per-consumer`** — the per-consumer cap keys on the same
54
+ name, so a named key is paced like a header-named consumer.
55
+
56
+ ## The file
57
+
58
+ `~/.dario/keys.json`, mode `0600`, holding a `sha256` hash per key and never
59
+ a secret. `DARIO_KEYS_PATH=<file>` / `--keys-path=<file>` moves it;
60
+ `--no-keys` / `DARIO_KEYS=0` ignores it (only `DARIO_API_KEY` authenticates).
61
+ The proxy re-reads the file when its mtime moves — one `stat` per
62
+ authenticated request, the cost of "no restart" — and records each key's
63
+ `last_used` with a debounced write that re-reads first, so it never clobbers
64
+ an edit the CLI made in between. A file that does not parse is reported and
65
+ the last good state stays in force: a bad edit does not lock everyone out,
66
+ and an empty read never silently revokes everyone.
67
+
68
+ Matching is constant-time over every record, every time, so a miss takes as
69
+ long as a hit and neither the number of keys nor which one matched leaks
70
+ through timing. A revoked, expired and unknown key are the same `401`.
71
+
72
+ The wire is untouched. dario already replaces the inbound key with the seat's
73
+ own bearer before upstream, so a named key changes what dario knows, not what
74
+ Anthropic or OpenAI sees — passthrough stays byte-identical.
75
+
76
+ ## Over HTTP
77
+
78
+ With the [admin API](./admin-api.md) on (`DARIO_ADMIN=1`, `DARIO_ADMIN_TOKEN`),
79
+ the same file is editable without shell access:
80
+
81
+ ```bash
82
+ curl -s -H "authorization: Bearer $DARIO_ADMIN_TOKEN" http://localhost:3456/admin/keys
83
+ curl -s -X POST -H "authorization: Bearer $DARIO_ADMIN_TOKEN" -H 'content-type: application/json' \
84
+ -d '{"name":"alice","seat":"work","models":["claude-sonnet-5"],"expires":"30d"}' \
85
+ http://localhost:3456/admin/keys
86
+ # -> 201 { "key": { "id", "name", "status", "seat", "models", "expires", … }, "secret": "dk_…" }
87
+ curl -s -X POST -H "authorization: Bearer $DARIO_ADMIN_TOKEN" http://localhost:3456/admin/keys/alice/rotate
88
+ curl -s -X DELETE -H "authorization: Bearer $DARIO_ADMIN_TOKEN" http://localhost:3456/admin/keys/alice
89
+ ```
90
+
91
+ Every mutation is audited by key name; the secret appears in exactly one
92
+ response and is never stored, listed or logged.
93
+
94
+ ## A keys-only proxy
95
+
96
+ A named key is additive: it never changes what a request *without* one gets.
97
+ On loopback with no `DARIO_API_KEY`, an anonymous request is still served, as
98
+ it always was; a request that presents a `dk_` credential that matches
99
+ nothing is refused, because a revoked key must mean refused, not anonymous.
100
+ For a deployment where only named keys should get in, set `DARIO_API_KEY` to
101
+ a long random value nobody is given — the non-loopback bind requires one
102
+ anyway — and hand out named keys.
103
+
104
+ ## Compared with a gateway
105
+
106
+ A LiteLLM-style gateway in front of dario does this with a database, a UI and
107
+ its own key format, and knows nothing about seats. A named key is a line in a
108
+ 0600 file, attribution rides the credential rather than a header the client
109
+ chooses, and the seat preference is something only the thing holding the
110
+ subscriptions can offer. Quotas per key — a share of a seat's 5-hour and
111
+ 7-day window — are the obvious next step and are deliberately not in this
112
+ version; the attribution they would need is.
@@ -174,10 +174,11 @@ The running proxy presents a rewritten identity on the seat's next request; no r
174
174
 
175
175
  A pool shared by a team serves several people through one `DARIO_API_KEY`, and until now nothing said whose traffic went where. A request can now name its consumer, and dario attributes and, optionally, paces by it:
176
176
 
177
+ - **A named key** (6.8) — `dario keys create alice` mints a credential whose name is the consumer. Attribution rides the credential, so a header cannot overrule it, and the key can prefer a seat or be held to a model allowlist. The rest of this section applies to it unchanged. See [Named keys](./keys.md).
177
178
  - **`x-dario-consumer: <name>`** — one printable token, up to 64 characters, no spaces. Set it per user in whatever fronts dario (LiteLLM's per-key headers, a reverse proxy, the client itself). This is the name the per-consumer cap keys on.
178
179
  - **Without the header**, attribution falls back to a hash of the body's user id: the Anthropic `metadata.user_id` (Claude Code sends `user_<hash>_account_<uuid>_session_<uuid>`; the session part is dropped, so one person is one key across sessions) or the OpenAI `user` field. The key is `u_` plus twelve hex characters — no account id or raw user id becomes an analytics key. The fallback is attribution only: the body is parsed after the concurrency slot is taken, so only the header can pace.
179
180
 
180
- Where it shows: `GET /analytics` gains `perConsumer` (requests, tokens, cache share, estimated cost, the seats the consumer landed on, last model) next to `perAccount`; every request log line and the `-v` usage line carry `consumer`; the TUI's Hits tab shows it on the selected request.
181
+ Where it shows: `GET /analytics` gains `perConsumer` (requests, tokens, cache share, estimated cost, the seats the consumer landed on, last model) next to `perAccount`, and `lifetime.perConsumer` splits the ledger the same way (`dario usage --by-key`); every request log line and the `-v` usage line carry `consumer`; the TUI's Hits tab shows it on the selected request.
181
182
 
182
183
  **Fairness.** `--max-concurrent-per-consumer=N` (`DARIO_MAX_CONCURRENT_PER_CONSUMER`) caps in-flight requests per named consumer. A consumer at the cap waits in the queue while slots are free for everyone else; when a slot frees, the first waiter whose consumer is under its cap is admitted, so one heavy user's backlog never holds up another user's next turn. Requests that name no consumer are never capped. Off by default — the plain `--max-concurrent` ceiling still applies to everyone together.
183
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.7.1",
3
+ "version": "6.8.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": {
@@ -86,7 +86,7 @@
86
86
  "node": ">=18.0.0"
87
87
  },
88
88
  "devDependencies": {
89
- "@types/node": "^26.0.0",
89
+ "@types/node": "^26.5.1",
90
90
  "tsx": "^4.19.0",
91
91
  "typescript": "^5.7.0"
92
92
  }