@askalf/dario 4.8.110 → 4.8.111

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.
@@ -7,7 +7,7 @@
7
7
  * `DARIO_API_KEY` as a fallback — even on loopback, since they add/remove
8
8
  * OAuth credentials):
9
9
  *
10
- * POST /admin/login/start { alias } -> { authorize_url, expires_at }
10
+ * POST /admin/login/start { alias? } -> { alias, authorize_url, expires_at }
11
11
  * POST /admin/login/complete { alias, code } -> { alias, status, expires_at }
12
12
  * GET /admin/accounts -> { accounts: [...], count }
13
13
  * DELETE /admin/accounts/<alias> -> { alias, removed }
@@ -17,18 +17,93 @@
17
17
  * the code Anthropic displays back to `/complete`. The PKCE verifier + state
18
18
  * live in an in-memory map keyed by the account `alias` with a short TTL — never
19
19
  * on disk, never returned to the client, single-use. One pending login per
20
- * alias; a second `/start` for the same alias just replaces it (#599).
20
+ * alias; a second `/start` for the same alias just replaces it (#599). The
21
+ * alias is optional on `/start`: omit it and a non-colliding default
22
+ * (`account-1`, …) is generated and returned for you to pass to `/complete`.
21
23
  *
22
- * Account changes take effect on the next proxy restart (the pool is built at
23
- * startup, src/proxy.ts). Hot-reload of a running pool is a deliberate
24
- * follow-up keeping this surface small while it manipulates credentials.
24
+ * `GET /admin/accounts` reports each account's persisted metadata alias,
25
+ * scopes, token expiry plus its live pool status (5h/7d utilization,
26
+ * representative-claim, routing status, request count) when the proxy supplies
27
+ * a `poolStatus` snapshot, which it does whenever pool mode is active. It's the
28
+ * headless, admin-token-gated equivalent of the `GET /accounts` pool view.
29
+ *
30
+ * Account changes take effect immediately: the proxy passes an
31
+ * `onAccountsChanged` hook (src/proxy.ts) that hot-reloads the live pool from
32
+ * disk, awaited before this handler responds, so an added account is routable
33
+ * by the time the client sees its 200 — no proxy restart (#599).
34
+ *
35
+ * Every mutation (login start / complete, account removal) and every auth
36
+ * reject is handed to an `audit` hook (src/proxy.ts) that records who did what
37
+ * to the account pool — to the console always, and to the JSON log file when
38
+ * one is configured. Secrets never reach it (#599).
39
+ *
40
+ * Mutations and repeated auth failures are rate-limited via an injected
41
+ * `rateLimit` hook (token bucket in src/rate-limit.ts, owned by the proxy):
42
+ * a throttled call returns `429` with `Retry-After` instead of acting. Reads
43
+ * (`GET /admin/accounts`) and successful auth are never throttled (#620).
25
44
  */
26
45
  import type { IncomingMessage, ServerResponse } from 'node:http';
46
+ /** Persisted account metadata surfaced by `GET /admin/accounts`. */
47
+ export interface AdminAccountRecord {
48
+ alias: string;
49
+ scopes: string[];
50
+ expiresAt: number;
51
+ }
52
+ /** Live per-account pool status keyed by alias — see `AdminDeps.poolStatus`. */
53
+ export interface AdminAccountLive {
54
+ util5h: number;
55
+ util7d: number;
56
+ claim: string;
57
+ status: string;
58
+ requestCount: number;
59
+ }
60
+ /** An audited admin action — see `AdminDeps.audit`. Never carries secrets. */
61
+ export interface AdminAuditEvent {
62
+ action: 'login_start' | 'login_complete' | 'account_remove' | 'auth_reject' | 'rate_limited';
63
+ ok: boolean;
64
+ status: number;
65
+ /** Account alias, when the action targets one. */
66
+ alias?: string;
67
+ /** Client address (`req.socket.remoteAddress`), when known. */
68
+ remote?: string;
69
+ /** Extra context, e.g. the rate-limited category ('auth' | 'mutation'). */
70
+ detail?: string;
71
+ }
27
72
  export interface AdminDeps {
28
73
  /** Admin bearer token buffer; `null` = enabled but no token configured (fail closed). */
29
74
  adminTokenBuf: Buffer | null;
30
- /** Invoked after an account is added or removed (e.g. to log / signal a reload). */
31
- onAccountsChanged?: () => void;
75
+ /**
76
+ * Invoked after an account is added or removed. Awaited before the response
77
+ * is sent, so a handler that hot-reloads the live pool (src/proxy.ts) makes
78
+ * the change routable by the time the client sees its 200.
79
+ */
80
+ onAccountsChanged?: () => void | Promise<void>;
81
+ /**
82
+ * Persisted-account inventory (alias, scopes, token expiry). Defaults to the
83
+ * on-disk store at `~/.dario/accounts`; injectable for tests.
84
+ */
85
+ listAccounts?: () => Promise<AdminAccountRecord[]>;
86
+ /**
87
+ * Live per-account pool status keyed by alias, from the running AccountPool.
88
+ * When present, `GET /admin/accounts` merges each account's 5h/7d headroom,
89
+ * representative-claim, routing status, and request count onto its persisted
90
+ * metadata. Returns `null` (or absent) in single-account mode — no pool.
91
+ */
92
+ poolStatus?: () => Map<string, AdminAccountLive> | null;
93
+ /**
94
+ * Audit sink for admin activity — every mutation (login start / complete,
95
+ * account removal) and every auth reject. The proxy records it (console +
96
+ * log file) so a headless operator has a trail of who provisioned or removed
97
+ * an account. Never receives secrets.
98
+ */
99
+ audit?: (event: AdminAuditEvent) => void;
100
+ /**
101
+ * Rate-limit gate for a request category. Consumes one token and returns `0`
102
+ * if allowed, or the milliseconds to wait if throttled. Applied to mutations
103
+ * (`'mutation'`) and to failed auth attempts (`'auth'`); reads and successful
104
+ * auth are never gated. Absent = no limiting. Owned by the proxy (#620).
105
+ */
106
+ rateLimit?: (category: 'auth' | 'mutation') => number;
32
107
  }
33
108
  /**
34
109
  * Handle an `/admin/*` request. Returns `true` if it owned the request (matched
package/dist/admin-api.js CHANGED
@@ -12,15 +12,38 @@ function prunePending(now) {
12
12
  pendingLogins.delete(id);
13
13
  }
14
14
  }
15
+ /** First `account-<n>` not already taken by an existing account or pending login. */
16
+ function nextDefaultAlias(taken) {
17
+ for (let n = 1;; n++) {
18
+ const candidate = `account-${n}`;
19
+ if (!taken.has(candidate))
20
+ return candidate; // taken is finite → always terminates
21
+ }
22
+ }
23
+ /** On-disk account inventory — the default `AdminDeps.listAccounts`. */
24
+ async function defaultListAccounts() {
25
+ const aliases = await listAccountAliases();
26
+ const loaded = await Promise.all(aliases.map(async (alias) => {
27
+ const a = await loadAccount(alias);
28
+ return a ? { alias: a.alias, scopes: a.scopes, expiresAt: a.expiresAt } : null;
29
+ }));
30
+ return loaded.filter((a) => a !== null);
31
+ }
15
32
  const HEADERS = {
16
33
  'Content-Type': 'application/json',
17
34
  'X-Content-Type-Options': 'nosniff',
18
35
  'Cache-Control': 'no-store',
19
36
  };
20
- function send(res, status, body) {
21
- res.writeHead(status, HEADERS);
37
+ function send(res, status, body, extraHeaders) {
38
+ res.writeHead(status, extraHeaders ? { ...HEADERS, ...extraHeaders } : HEADERS);
22
39
  res.end(JSON.stringify(body));
23
40
  }
41
+ /** Emit a 429 with `Retry-After` and audit the throttle. */
42
+ function sendThrottled(res, waitMs, category, audit, remote, alias) {
43
+ const retryAfterS = Math.max(1, Math.ceil(waitMs / 1000));
44
+ audit?.({ action: 'rate_limited', ok: false, status: 429, remote, alias, detail: category });
45
+ send(res, 429, { error: 'rate limited', message: `too many admin requests; retry in ~${retryAfterS}s` }, { 'Retry-After': String(retryAfterS) });
46
+ }
24
47
  /** Constant-time bearer / x-api-key check. Fails closed when no token configured. */
25
48
  function adminAuthOk(req, tokenBuf) {
26
49
  if (!tokenBuf)
@@ -71,6 +94,7 @@ async function readJsonBody(req, limitBytes = 64 * 1024) {
71
94
  */
72
95
  export async function handleAdminRequest(req, res, urlPath, deps) {
73
96
  const method = req.method ?? 'GET';
97
+ const remote = req.socket?.remoteAddress;
74
98
  const isAccountDelete = method === 'DELETE' && urlPath.startsWith(ACCOUNTS_PREFIX) && urlPath.length > ACCOUNTS_PREFIX.length;
75
99
  const known = urlPath === '/admin/login/start' ||
76
100
  urlPath === '/admin/login/complete' ||
@@ -80,6 +104,14 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
80
104
  return false;
81
105
  // Auth — always required, even on loopback (these mutate OAuth credentials).
82
106
  if (!adminAuthOk(req, deps.adminTokenBuf)) {
107
+ // Throttle repeated failures (brute force / broken client) before replying.
108
+ const wait = deps.rateLimit?.('auth') ?? 0;
109
+ if (wait > 0) {
110
+ sendThrottled(res, wait, 'auth', deps.audit, remote);
111
+ return true;
112
+ }
113
+ const status = deps.adminTokenBuf ? 401 : 403;
114
+ deps.audit?.({ action: 'auth_reject', ok: false, status, remote });
83
115
  if (!deps.adminTokenBuf) {
84
116
  send(res, 403, { error: 'admin API enabled but no token configured — set DARIO_ADMIN_TOKEN (or DARIO_API_KEY)' });
85
117
  }
@@ -88,20 +120,34 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
88
120
  }
89
121
  return true;
90
122
  }
123
+ // Rate-limit the mutating routes (reads are exempt). Runs after auth so an
124
+ // unauthenticated flood is handled by the 'auth' bucket above, not this one.
125
+ const isMutation = urlPath === '/admin/login/start' || urlPath === '/admin/login/complete' || isAccountDelete;
126
+ if (isMutation) {
127
+ const wait = deps.rateLimit?.('mutation') ?? 0;
128
+ if (wait > 0) {
129
+ sendThrottled(res, wait, 'mutation', deps.audit, remote);
130
+ return true;
131
+ }
132
+ }
91
133
  const now = Date.now();
92
134
  prunePending(now);
93
135
  try {
94
- // POST /admin/login/start { alias }
136
+ // POST /admin/login/start { alias? }
95
137
  if (urlPath === '/admin/login/start') {
96
138
  if (method !== 'POST') {
97
139
  send(res, 405, { error: 'Method not allowed (use POST)' });
98
140
  return true;
99
141
  }
100
142
  const body = await readJsonBody(req);
101
- const alias = typeof body.alias === 'string' ? body.alias.trim() : '';
143
+ let alias = typeof body.alias === 'string' ? body.alias.trim() : '';
144
+ // Alias is optional: a headless caller can omit it and get a generated,
145
+ // non-colliding default (account-1, account-2, …) back in the response to
146
+ // thread into /complete. Explicit aliases still win for named setups.
102
147
  if (!alias) {
103
- send(res, 400, { error: 'missing "alias"' });
104
- return true;
148
+ const records = await (deps.listAccounts ?? defaultListAccounts)();
149
+ const taken = new Set([...records.map((r) => r.alias), ...pendingLogins.keys()]);
150
+ alias = nextDefaultAlias(taken);
105
151
  }
106
152
  // Only a brand-new alias grows the map; a repeat /start replaces in place.
107
153
  if (!pendingLogins.has(alias) && pendingLogins.size >= MAX_PENDING) {
@@ -111,7 +157,9 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
111
157
  const { authorizeUrl, codeVerifier, state } = await startAddAccount(alias); // throws on invalid alias
112
158
  const expiresAt = now + PENDING_TTL_MS;
113
159
  pendingLogins.set(alias, { codeVerifier, state, expiresAt });
160
+ deps.audit?.({ action: 'login_start', ok: true, status: 200, alias, remote });
114
161
  send(res, 200, {
162
+ alias,
115
163
  authorize_url: authorizeUrl,
116
164
  expires_at: new Date(expiresAt).toISOString(),
117
165
  instructions: `Open authorize_url, approve, then POST { "alias": "${alias}", "code": "<displayed code>" } to /admin/login/complete.`,
@@ -149,27 +197,41 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
149
197
  }
150
198
  pendingLogins.delete(alias); // single-use, regardless of exchange outcome
151
199
  const creds = await completeAddAccount(alias, code, p.codeVerifier, p.state);
152
- deps.onAccountsChanged?.();
200
+ await deps.onAccountsChanged?.();
201
+ deps.audit?.({ action: 'login_complete', ok: true, status: 200, alias: creds.alias, remote });
153
202
  send(res, 200, { alias: creds.alias, status: 'added', expires_at: new Date(creds.expiresAt).toISOString() });
154
203
  return true;
155
204
  }
156
- // GET /admin/accounts
205
+ // GET /admin/accounts — persisted metadata + live pool status (#599).
157
206
  if (urlPath === '/admin/accounts') {
158
207
  if (method !== 'GET') {
159
208
  send(res, 405, { error: 'Method not allowed (use GET)' });
160
209
  return true;
161
210
  }
162
- const aliases = await listAccountAliases();
163
- const accounts = (await Promise.all(aliases.map(async (alias) => {
164
- const a = await loadAccount(alias);
165
- if (!a)
166
- return null;
167
- return { alias: a.alias, scopes: a.scopes, expires_in_ms: Math.max(0, a.expiresAt - now) };
168
- }))).filter((a) => a !== null);
211
+ const records = await (deps.listAccounts ?? defaultListAccounts)();
212
+ const live = deps.poolStatus?.() ?? null;
213
+ const accounts = records.map((r) => {
214
+ const l = live?.get(r.alias);
215
+ return {
216
+ alias: r.alias,
217
+ scopes: r.scopes,
218
+ expires_in_ms: Math.max(0, r.expiresAt - now),
219
+ // Inline the running pool's live status when this account is in it.
220
+ ...(l ? {
221
+ util5h: l.util5h,
222
+ util7d: l.util7d,
223
+ claim: l.claim,
224
+ status: l.status,
225
+ request_count: l.requestCount,
226
+ } : {}),
227
+ };
228
+ });
169
229
  send(res, 200, {
170
230
  accounts,
171
231
  count: accounts.length,
172
- note: 'live rate-limit / utilization is at GET /accounts when pool mode is active',
232
+ // Live util/claim/status is inlined above when the pool is active;
233
+ // single-account mode has no pool, so point at the pool view instead.
234
+ ...(live ? {} : { note: 'live rate-limit / utilization is at GET /accounts when pool mode is active' }),
173
235
  });
174
236
  return true;
175
237
  }
@@ -178,7 +240,8 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
178
240
  const alias = decodeURIComponent(urlPath.slice(ACCOUNTS_PREFIX.length));
179
241
  const removed = await removeAccount(alias); // validates alias internally
180
242
  if (removed)
181
- deps.onAccountsChanged?.();
243
+ await deps.onAccountsChanged?.();
244
+ deps.audit?.({ action: 'account_remove', ok: removed, status: removed ? 200 : 404, alias, remote });
182
245
  send(res, removed ? 200 : 404, { alias, removed });
183
246
  return true;
184
247
  }
@@ -83,6 +83,21 @@ export declare const NO_BILLING_CLAIM = "unknown";
83
83
  * the proxy on every transient non-200/stream-abort.
84
84
  */
85
85
  export declare function isNonSubscriptionBilling(claim: string | null | undefined): boolean;
86
+ interface Rate {
87
+ input: number;
88
+ output: number;
89
+ cacheRead: number;
90
+ cacheCreate: number;
91
+ }
92
+ /**
93
+ * The per-1M-token rate for `model` in effect at `atMs` (epoch ms): the intro
94
+ * rate while within its window, otherwise the standard rate. A trailing context
95
+ * tag (`claude-sonnet-5[1m]`, `claude-opus-4-7[1m]`) is stripped before lookup —
96
+ * the [1m] ids used to fall through to the sonnet fallback and bill at the wrong
97
+ * family's rate. Unknown models fall back to the sonnet-4-6 rate. Exported for
98
+ * tests.
99
+ */
100
+ export declare function pricingRateFor(model: string, atMs: number): Rate;
86
101
  export declare class Analytics extends EventEmitter {
87
102
  private records;
88
103
  private maxRecords;
package/dist/analytics.js CHANGED
@@ -74,26 +74,47 @@ export function isNonSubscriptionBilling(claim) {
74
74
  return false;
75
75
  return !SUBSCRIPTION_CLAIMS.has(claim);
76
76
  }
77
- // Anthropic pricing (per 1M tokens, USD). Not authoritative — used for
78
- // rough burn-rate display in the /analytics summary.
79
77
  const PRICING = {
80
- // Fable 5 official per-token pricing wasn't published at integration time
81
- // (2026-06-09) assumed at the current flagship (opus-4-8) rate. Display-only.
82
- 'claude-fable-5': { input: 5, output: 25, cacheRead: 0.5, cacheCreate: 6.25 },
78
+ // Fable 5 official pricing (published with the 2026-07-01 redeploy):
79
+ // $10/$50 per 1M in/out, 5m cache-write $12.50, cache-read $1 (platform docs).
80
+ // Was previously assumed at the opus-4-8 rate ($5/$25) corrected here.
81
+ 'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheCreate: 12.5 },
83
82
  'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheCreate: 6.25 },
84
83
  'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheCreate: 6.25 },
85
- 'claude-opus-4-6': { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },
86
- // Sonnet 5 standard $3/$15; intro $2/$10 through 2026-08-31 (display estimate, not date-modeled).
87
- 'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },
84
+ // Opus 4.6 is $5/$25 (same as 4.7/4.8), not the old $15/$75 Opus-4.1 rate.
85
+ 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheCreate: 6.25 },
86
+ // Sonnet 5 standard $3/$15; launch intro $2/$10 through 2026-08-31, then
87
+ // standard. Cache rates follow Anthropic's usual 0.1x-read / 1.25x-write of
88
+ // input. Date-modeled below (was a flat display estimate before).
89
+ 'claude-sonnet-5': {
90
+ input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75,
91
+ intro: { input: 2, output: 10, cacheRead: 0.2, cacheCreate: 2.5, until: '2026-08-31' },
92
+ },
88
93
  'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },
89
94
  'claude-haiku-4-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheCreate: 1 },
90
95
  };
96
+ /**
97
+ * The per-1M-token rate for `model` in effect at `atMs` (epoch ms): the intro
98
+ * rate while within its window, otherwise the standard rate. A trailing context
99
+ * tag (`claude-sonnet-5[1m]`, `claude-opus-4-7[1m]`) is stripped before lookup —
100
+ * the [1m] ids used to fall through to the sonnet fallback and bill at the wrong
101
+ * family's rate. Unknown models fall back to the sonnet-4-6 rate. Exported for
102
+ * tests.
103
+ */
104
+ export function pricingRateFor(model, atMs) {
105
+ const baseModel = model.replace(/\[[^\]]*\]$/, '');
106
+ const entry = PRICING[baseModel] ?? PRICING['claude-sonnet-4-6'];
107
+ if (entry.intro && atMs <= Date.parse(`${entry.intro.until}T23:59:59.999Z`)) {
108
+ const { until: _until, ...introRate } = entry.intro;
109
+ return introRate;
110
+ }
111
+ return { input: entry.input, output: entry.output, cacheRead: entry.cacheRead, cacheCreate: entry.cacheCreate };
112
+ }
91
113
  function estimateCost(record) {
92
- // Strip a trailing context tag (`claude-fable-5[1m]`, `claude-opus-4-7[1m]`)
93
- // before the lookup the [1m] ids billed at the wrong family's rate before
94
- // (fell through to the sonnet fallback).
95
- const baseModel = record.model.replace(/\[[^\]]*\]$/, '');
96
- const p = PRICING[baseModel] ?? PRICING['claude-sonnet-4-6'];
114
+ // Price each record at the rate effective at ITS OWN timestamp, so a window
115
+ // that spans a pricing cutover (e.g. Sonnet 5's intro ending 2026-08-31)
116
+ // estimates each side correctly rather than repricing history at today's rate.
117
+ const p = pricingRateFor(record.model, record.timestamp);
97
118
  return ((record.inputTokens * p.input) +
98
119
  (record.outputTokens * p.output) +
99
120
  (record.cacheReadTokens * p.cacheRead) +
@@ -49,19 +49,24 @@ export const BAKED_BASE_MODELS = [
49
49
  * them, even when upstream still lists the id, because api.anthropic.com
50
50
  * returns `not_found` for every request to them.
51
51
  *
52
- * Claude Fable 5 AND Mythos 5 were disabled for ALL Anthropic customers by a
53
- * US-government legal directive on 2026-06-12 (all plans/tiers; other models
54
- * unaffected) https://www.anthropic.com/news/fable-mythos-access. dario's
55
- * baked fallback still carries `claude-fable-5`, and upstream may still list
56
- * it, so without this filter dario keeps advertising a model that 404s.
52
+ * NOW EMPTY. Claude Fable 5 AND Mythos 5 were disabled for all Anthropic
53
+ * customers by a US-government export-control directive on 2026-06-12, so dario
54
+ * defaulted to suspending the `fable` family (it would otherwise advertise a
55
+ * model that 404s). The directive was lifted and **Fable 5 returned globally on
56
+ * 2026-07-01** Claude Platform, Claude.ai, Claude Code, and Cowork, including
57
+ * Pro/Max/Team (up to 50% of weekly limits through 2026-07-07, then via credits)
58
+ * — https://www.anthropic.com/news/redeploying-fable-5. dario's subscription
59
+ * path runs on that surface, so the default suspension is removed here.
60
+ * (Mythos 5 is restored only to a set of US organizations, not globally — dario
61
+ * never carried it, so nothing to do there.)
57
62
  *
58
- * TEMP reversible without a code change: `DARIO_SUSPENDED_MODELS` overrides
59
- * the default (comma-separated families or model ids; each is normalized to
60
- * its family). Set `DARIO_SUSPENDED_MODELS=` (empty) to re-enable everything
61
- * once access is restored. Matching is by FAMILY, so every spelling is caught:
62
- * `fable`, `fable1m`, `claude-fable-5`, `claude-fable-5[1m]`, `claude:fable`.
63
+ * The mechanism is retained for future use: `DARIO_SUSPENDED_MODELS` (comma-
64
+ * separated families or model ids, each normalized to its family) suspends by
65
+ * FAMILY, so every spelling is caught — `fable`, `fable1m`, `claude-fable-5`,
66
+ * `claude-fable-5[1m]`, `claude:fable`. Set e.g. `DARIO_SUSPENDED_MODELS=fable`
67
+ * to re-suspend if access is ever pulled again.
63
68
  */
64
- const DEFAULT_SUSPENDED_MODELS = 'fable';
69
+ const DEFAULT_SUSPENDED_MODELS = '';
65
70
  /** The set of suspended families, resolved from env at call time. */
66
71
  export function suspendedFamilies() {
67
72
  const raw = process.env.DARIO_SUSPENDED_MODELS ?? DEFAULT_SUSPENDED_MODELS;
package/dist/pool.d.ts CHANGED
@@ -198,3 +198,26 @@ export declare class AccountPool {
198
198
  waitForAccount(): Promise<PoolAccount>;
199
199
  private drainQueue;
200
200
  }
201
+ /** Minimal account shape the pool needs to route — a structural subset of
202
+ * accounts.ts' AccountCredentials, declared here to keep pool.ts dependency-free. */
203
+ export interface ReconcilableAccount {
204
+ alias: string;
205
+ accessToken: string;
206
+ refreshToken: string;
207
+ expiresAt: number;
208
+ deviceId: string;
209
+ accountUuid: string;
210
+ }
211
+ /**
212
+ * Reconcile a live pool against the current on-disk account set: add or refresh
213
+ * the tokens of every account that exists on disk, and drop any the pool still
214
+ * holds that no longer does. `add` preserves a known alias's rate-limit and
215
+ * identity state, so re-adding an unchanged account is a cheap token refresh
216
+ * rather than a reset.
217
+ *
218
+ * This is the hot-reload primitive behind the headless admin API (#599): the
219
+ * proxy calls it from `onAccountsChanged` so accounts provisioned or removed
220
+ * over HTTP take effect immediately, with no proxy restart. Returns the pool
221
+ * size after reconciliation.
222
+ */
223
+ export declare function reconcilePoolAccounts(pool: AccountPool, accounts: ReconcilableAccount[]): number;
package/dist/pool.js CHANGED
@@ -464,3 +464,32 @@ export class AccountPool {
464
464
  }
465
465
  }
466
466
  }
467
+ /**
468
+ * Reconcile a live pool against the current on-disk account set: add or refresh
469
+ * the tokens of every account that exists on disk, and drop any the pool still
470
+ * holds that no longer does. `add` preserves a known alias's rate-limit and
471
+ * identity state, so re-adding an unchanged account is a cheap token refresh
472
+ * rather than a reset.
473
+ *
474
+ * This is the hot-reload primitive behind the headless admin API (#599): the
475
+ * proxy calls it from `onAccountsChanged` so accounts provisioned or removed
476
+ * over HTTP take effect immediately, with no proxy restart. Returns the pool
477
+ * size after reconciliation.
478
+ */
479
+ export function reconcilePoolAccounts(pool, accounts) {
480
+ const wanted = new Set(accounts.map(a => a.alias));
481
+ for (const a of accounts) {
482
+ pool.add(a.alias, {
483
+ accessToken: a.accessToken,
484
+ refreshToken: a.refreshToken,
485
+ expiresAt: a.expiresAt,
486
+ deviceId: a.deviceId,
487
+ accountUuid: a.accountUuid,
488
+ });
489
+ }
490
+ for (const existing of pool.all()) {
491
+ if (!wanted.has(existing.alias))
492
+ pool.remove(existing.alias);
493
+ }
494
+ return pool.size;
495
+ }
package/dist/proxy.d.ts CHANGED
@@ -410,6 +410,7 @@ export interface ProxyLogEntry {
410
410
  stream?: boolean;
411
411
  reject?: string;
412
412
  error?: string;
413
+ event?: string;
413
414
  }
414
415
  /**
415
416
  * Append a JSON-ND line to the proxy log file. No-op when stream is
package/dist/proxy.js CHANGED
@@ -11,12 +11,13 @@ import { buildHealthResponse } from './health-response.js';
11
11
  import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, CC_TEMPLATE } from './cc-template.js';
12
12
  import { stampCch } from './cch.js';
13
13
  import { describeTemplate, detectDrift, checkCCCompat } from './live-fingerprint.js';
14
- import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs } from './pool.js';
14
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, reconcilePoolAccounts } from './pool.js';
15
15
  import { Analytics, billingBucketFromClaim } from './analytics.js';
16
16
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
17
17
  import { notify as osNotify } from './notify.js';
18
- import { loadAllAccounts, loadAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale } from './accounts.js';
18
+ import { loadAllAccounts, loadAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool } from './accounts.js';
19
19
  import { handleAdminRequest } from './admin-api.js';
20
+ import { createTokenBucket } from './rate-limit.js';
20
21
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
21
22
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
22
23
  import { redactSecrets } from './redact.js';
@@ -827,8 +828,9 @@ export async function startProxy(opts = {}) {
827
828
  if (openaiBackend) {
828
829
  console.log(` OpenAI-compat backend: ${openaiBackend.name} → ${openaiBackend.baseUrl}`);
829
830
  }
830
- // Multi-account pool — activated when ~/.dario/accounts/ has 2+ entries.
831
- // Single-account dario keeps its existing code path unchanged.
831
+ // Multi-account pool — activated when ~/.dario/accounts/ has 2+ entries (or
832
+ // whenever admin mode is on; see the #599 block below). Single-account dario
833
+ // keeps its existing code path unchanged.
832
834
  //
833
835
  // Before loading the pool, check whether the back-filled `login` snapshot
834
836
  // has gone stale relative to credentials.json (dario#235). The single-
@@ -839,8 +841,24 @@ export async function startProxy(opts = {}) {
839
841
  if (resyncResult === 'resynced') {
840
842
  console.log('[dario] re-synced pool `login` account from current credentials.json (was stale; dario#235)');
841
843
  }
844
+ // Admin mode (#599) manages the account pool over HTTP, so route through the
845
+ // pool engine even for 0–1 accounts. This is what makes a headless deploy
846
+ // work: an empty pool returns a clean 503 (not the single-account path's
847
+ // generic upstream error), and accounts added via POST /admin/login/* take
848
+ // effect immediately — no proxy restart (see onAccountsChanged below).
849
+ //
850
+ // Back-fill first: an operator who enables admin mode on top of an existing
851
+ // single-account `dario login` setup keeps that account (ensureLogin… is a
852
+ // no-op when accounts/ is already populated or no credentials are reachable).
853
+ const adminEnabled = process.env.DARIO_ADMIN === '1';
854
+ if (adminEnabled) {
855
+ try {
856
+ await ensureLoginCredentialsInPool();
857
+ }
858
+ catch { /* non-fatal — pool just starts empty */ }
859
+ }
842
860
  const accountsList = await loadAllAccounts();
843
- const pool = accountsList.length >= 2 ? new AccountPool() : null;
861
+ const pool = (accountsList.length >= 2 || adminEnabled) ? new AccountPool() : null;
844
862
  // Per-model rate-limit bucket families seen during this proxy run. First-
845
863
  // sight is logged once when verbose so a new Anthropic bucket (e.g. an
846
864
  // eventual `7d_opus`) doesn't slip past unnoticed. Pure observability —
@@ -914,31 +932,32 @@ export async function startProxy(opts = {}) {
914
932
  }, 15 * 60 * 1000);
915
933
  refreshInterval.unref();
916
934
  // Pool mode doesn't check single-account status — compute a placeholder
917
- // for the startup banner using the pool's earliest expiry.
918
- const earliest = Math.min(...pool.all().map(a => a.expiresAt));
919
- const msLeft = Math.max(0, earliest - Date.now());
920
- status = {
921
- authenticated: true,
922
- status: 'healthy',
923
- expiresAt: earliest,
924
- expiresIn: `${Math.floor(msLeft / 3600000)}h ${Math.floor((msLeft % 3600000) / 60000)}m`,
925
- };
935
+ // for the startup banner using the pool's earliest expiry. An admin-mode
936
+ // pool can legitimately start empty (#599): report that plainly instead of
937
+ // Math.min([]) === Infinity, and tell the operator how to bootstrap.
938
+ if (pool.size === 0) {
939
+ console.warn('[dario] Starting with no accounts (admin mode). Add one via POST /admin/login/start; LLM requests return 503 until then.');
940
+ status = { authenticated: false, status: 'none', expiresAt: 0, expiresIn: 'no accounts yet' };
941
+ }
942
+ else {
943
+ const earliest = Math.min(...pool.all().map(a => a.expiresAt));
944
+ const msLeft = Math.max(0, earliest - Date.now());
945
+ status = {
946
+ authenticated: true,
947
+ status: 'healthy',
948
+ expiresAt: earliest,
949
+ expiresIn: `${Math.floor(msLeft / 3600000)}h ${Math.floor((msLeft % 3600000) / 60000)}m`,
950
+ };
951
+ }
926
952
  }
927
953
  else {
928
- // Single-account mode — existing auth check
954
+ // Single-account mode — the classic `dario login` path. Admin mode never
955
+ // lands here: it always routes through the pool above (#599), which starts
956
+ // even with zero accounts and returns a clean 503 until one is added.
929
957
  status = await getStatus();
930
958
  if (!status.authenticated) {
931
- if (process.env.DARIO_ADMIN === '1') {
932
- // Admin API on (#599): start anyway so POST /admin/login/start can
933
- // bootstrap the first account headlessly — the whole point of the API
934
- // is "no console access". LLM routes return 503 until an account
935
- // exists. Scoped to DARIO_ADMIN=1; default dario still exits here.
936
- console.warn('[dario] No credentials yet — starting in admin-only mode (DARIO_ADMIN=1). Add an account via POST /admin/login/start (or run `dario login`); LLM requests return 503 until then.');
937
- }
938
- else {
939
- console.error('[dario] Not authenticated. Run `dario login` first.');
940
- process.exit(1);
941
- }
959
+ console.error('[dario] Not authenticated. Run `dario login` first.');
960
+ process.exit(1);
942
961
  }
943
962
  }
944
963
  const cliVersion = detectCliVersion();
@@ -1131,12 +1150,22 @@ export async function startProxy(opts = {}) {
1131
1150
  // these endpoints add/remove OAuth accounts: the admin token is
1132
1151
  // DARIO_ADMIN_TOKEN, falling back to DARIO_API_KEY. Enabled-but-tokenless
1133
1152
  // fails closed (403) so account control is never left open on loopback.
1134
- const adminEnabled = process.env.DARIO_ADMIN === '1';
1153
+ // adminEnabled is resolved once up top (it gates pool activation). Reuse it.
1135
1154
  const adminToken = process.env.DARIO_ADMIN_TOKEN || process.env.DARIO_API_KEY || '';
1136
1155
  const adminTokenBuf = adminToken ? Buffer.from(adminToken) : null;
1137
1156
  if (adminEnabled) {
1138
1157
  console.log(`[dario] admin API enabled at /admin/* (token ${adminTokenBuf ? 'configured' : 'MISSING — endpoints return 403 until DARIO_ADMIN_TOKEN is set'})`);
1139
1158
  }
1159
+ // Admin rate limiting (#620). Two token buckets, created once per proxy:
1160
+ // failed auth (brute-force / broken-client throttle) and mutations. Global,
1161
+ // not per-IP — the surface is loopback-default so remoteAddress collapses to
1162
+ // 127.0.0.1 (and behind a tunnel it's the tunnel address), making a single
1163
+ // bucket per category simpler and sufficient. Disable with
1164
+ // DARIO_ADMIN_RATE_LIMIT=off. Defaults are generous for a human operator +
1165
+ // scripts and only bite runaway callers.
1166
+ const adminRateLimitOff = /^(off|0|false)$/i.test(process.env.DARIO_ADMIN_RATE_LIMIT ?? '');
1167
+ const adminAuthBucket = createTokenBucket(10, 0.5); // 10 burst, then 1 / 2s
1168
+ const adminMutationBucket = createTokenBucket(30, 1); // 30 burst, then 1 / 1s
1140
1169
  // CORS origin defaults to the localhost URL the proxy is served at. Users
1141
1170
  // binding to a non-loopback address (e.g. a Tailscale interface) can
1142
1171
  // override via DARIO_CORS_ORIGIN — otherwise browser-based clients hitting
@@ -1210,9 +1239,68 @@ export async function startProxy(opts = {}) {
1210
1239
  if (adminEnabled && urlPath.startsWith('/admin/')) {
1211
1240
  const handled = await handleAdminRequest(req, res, urlPath, {
1212
1241
  adminTokenBuf,
1213
- onAccountsChanged: () => {
1214
- if (verbose)
1215
- console.log('[dario] admin: account pool changed on disk (effective on next proxy restart)');
1242
+ onAccountsChanged: async () => {
1243
+ // Hot-reload the live pool from disk so accounts added / removed via
1244
+ // the admin API take effect immediately no proxy restart (#599).
1245
+ // Awaited by handleAdminRequest before it responds, so the account is
1246
+ // already routable by the time the client sees the 200.
1247
+ if (!pool)
1248
+ return;
1249
+ try {
1250
+ const size = reconcilePoolAccounts(pool, await loadAllAccounts());
1251
+ if (verbose)
1252
+ console.log(`[dario] admin: pool hot-reloaded — ${size} account${size === 1 ? '' : 's'}`);
1253
+ }
1254
+ catch (err) {
1255
+ console.error(`[dario] admin: pool hot-reload failed: ${err instanceof Error ? err.message : err}`);
1256
+ }
1257
+ },
1258
+ // Live per-account status so GET /admin/accounts reports headroom, not
1259
+ // just persisted metadata — the same snapshot GET /accounts exposes.
1260
+ poolStatus: () => {
1261
+ if (!pool)
1262
+ return null;
1263
+ const snapNow = Date.now();
1264
+ const snap = new Map();
1265
+ for (const a of pool.all()) {
1266
+ snap.set(a.alias, {
1267
+ util5h: a.rateLimit.util5h,
1268
+ util7d: a.rateLimit.util7d,
1269
+ claim: a.rateLimit.claim,
1270
+ status: isInAuthCooldown(a, snapNow) ? 'auth-cooldown' : a.rateLimit.status,
1271
+ requestCount: a.requestCount,
1272
+ });
1273
+ }
1274
+ return snap;
1275
+ },
1276
+ // Audit trail for admin activity. Console always (headless operators
1277
+ // read container stdout; the JSON log file is opt-in), plus a structured
1278
+ // line when --log-file / DARIO_LOG_FILE is set.
1279
+ audit: (e) => {
1280
+ const detail = e.detail ? ` detail=${e.detail}` : '';
1281
+ const line = `[dario] admin-audit: ${e.action} alias=${e.alias ?? '-'} ok=${e.ok} status=${e.status} from=${e.remote ?? '-'}${detail}`;
1282
+ if (e.ok)
1283
+ console.log(line);
1284
+ else
1285
+ console.warn(line);
1286
+ writeLogLine(logFileStream, {
1287
+ ts: new Date().toISOString(),
1288
+ req: 0,
1289
+ method: req.method ?? '',
1290
+ path: urlPath,
1291
+ status: e.status,
1292
+ event: `admin.${e.action}`,
1293
+ account: e.alias,
1294
+ reject: e.ok ? undefined : 'admin-auth',
1295
+ });
1296
+ },
1297
+ // Token-bucket gate (#620). 0 = allowed (token consumed); >0 = throttled,
1298
+ // the ms to wait. Off switch short-circuits to always-allowed.
1299
+ rateLimit: (category) => {
1300
+ if (adminRateLimitOff)
1301
+ return 0;
1302
+ const bucket = category === 'auth' ? adminAuthBucket : adminMutationBucket;
1303
+ return bucket.tryRemove() ? 0 : bucket.retryAfterMs();
1216
1304
  },
1217
1305
  });
1218
1306
  if (handled)
@@ -1520,8 +1608,23 @@ export async function startProxy(opts = {}) {
1520
1608
  else if (pool) {
1521
1609
  poolAccount = pool.select();
1522
1610
  if (!poolAccount) {
1611
+ // Two distinct empty-selection cases (#599): the pool has no accounts
1612
+ // at all (headless admin bootstrap — nothing added yet), vs. it has
1613
+ // accounts but all are rate-limited / in auth cool-down. Give each a
1614
+ // truthful, actionable message so a headless operator isn't told
1615
+ // "rate-limited" when they simply haven't added an account.
1523
1616
  res.writeHead(503, JSON_HEADERS);
1524
- res.end(JSON.stringify({ error: 'No accounts available in pool' }));
1617
+ res.end(JSON.stringify(pool.size === 0
1618
+ ? {
1619
+ error: 'No account configured',
1620
+ message: adminEnabled
1621
+ ? 'dario is running in admin mode with no account yet. Add one via POST /admin/login/start, then retry.'
1622
+ : 'No accounts available. Run `dario login`, or add accounts with `dario accounts add`.',
1623
+ }
1624
+ : {
1625
+ error: 'No accounts available in pool',
1626
+ message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
1627
+ }));
1525
1628
  return;
1526
1629
  }
1527
1630
  accessToken = poolAccount.accessToken;
@@ -1681,13 +1784,12 @@ export async function startProxy(opts = {}) {
1681
1784
  const result = isOpenAI ? openaiToAnthropic(parsed, modelOverride) : (modelOverride ? { ...parsed, model: modelOverride } : parsed);
1682
1785
  const r = result;
1683
1786
  requestModel = (r.model || '').toLowerCase();
1684
- // Suspended-model guard. Fable 5 / Mythos 5 are disabled for ALL
1685
- // Anthropic customers (US-gov directive 2026-06-12); forwarding any
1686
- // spelling of them 404s upstream with a confusing not_found. Reject
1687
- // up front with an actionable error instead runs before the
1688
- // template build / account lease / forwarding, so nothing to unwind.
1689
- // TEMP + reversible: governed by DARIO_SUSPENDED_MODELS (default
1690
- // 'fable'); set it empty to re-enable when access is restored.
1787
+ // Suspended-model guard. Empty by default (Fable 5 returned globally
1788
+ // 2026-07-01, so nothing is suspended out of the box). Operators can
1789
+ // still suspend a family via DARIO_SUSPENDED_MODELS e.g. a model
1790
+ // that 404s upstream and this rejects any spelling of it up front
1791
+ // with an actionable error, before the template build / account lease
1792
+ // / forwarding, so there's nothing to unwind.
1691
1793
  if (requestModel && isSuspendedModel(requestModel)) {
1692
1794
  if (verbose)
1693
1795
  console.log(`[dario] suspended model rejected: ${requestModel} (${urlPath})`);
@@ -1696,7 +1798,7 @@ export async function startProxy(opts = {}) {
1696
1798
  type: 'error',
1697
1799
  error: {
1698
1800
  type: 'not_found_error',
1699
- message: `Model "${requestModel}" is suspended in this dario instance. Claude Fable 5 and Mythos 5 are disabled for all Anthropic customers (US government directive 2026-06-12 — https://www.anthropic.com/news/fable-mythos-access). Use claude-opus-4-8 or claude-sonnet-4-6 instead. To re-enable when access is restored, set DARIO_SUSPENDED_MODELS= (empty) on the dario instance.`,
1801
+ message: `Model "${requestModel}" is suspended in this dario instance via DARIO_SUSPENDED_MODELS. Use claude-opus-4-8 or claude-sonnet-4-6 instead, or clear that family from DARIO_SUSPENDED_MODELS to re-enable it.`,
1700
1802
  },
1701
1803
  }));
1702
1804
  return;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Minimal token-bucket rate limiter (#620).
3
+ *
4
+ * Zero-dependency, in-memory, monotonic-time refill. Used to throttle the
5
+ * admin API's mutations and repeated auth failures (src/admin-api.ts), but
6
+ * kept generic. The clock is injectable so the refill math is deterministic
7
+ * under test — no `sleep`.
8
+ *
9
+ * `tryRemove()` consumes one token if any are available; `retryAfterMs()`
10
+ * reports how long until the next token, for a `Retry-After` header.
11
+ */
12
+ export interface RateLimiter {
13
+ /** Consume one token. Returns true if allowed, false if the bucket is empty. */
14
+ tryRemove(nowMs?: number): boolean;
15
+ /** Milliseconds until at least one token is available (0 if available now). */
16
+ retryAfterMs(nowMs?: number): number;
17
+ }
18
+ /**
19
+ * @param capacity max tokens (burst size); also the starting fill.
20
+ * @param refillPerSec tokens added per second (fractional allowed).
21
+ * @param now clock in ms; injectable for tests.
22
+ */
23
+ export declare function createTokenBucket(capacity: number, refillPerSec: number, now?: () => number): RateLimiter;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @param capacity max tokens (burst size); also the starting fill.
3
+ * @param refillPerSec tokens added per second (fractional allowed).
4
+ * @param now clock in ms; injectable for tests.
5
+ */
6
+ export function createTokenBucket(capacity, refillPerSec, now = Date.now) {
7
+ let tokens = capacity;
8
+ let last = now();
9
+ function refill(t) {
10
+ if (t <= last)
11
+ return; // clock didn't advance — nothing to add
12
+ tokens = Math.min(capacity, tokens + ((t - last) / 1000) * refillPerSec);
13
+ last = t;
14
+ }
15
+ return {
16
+ tryRemove(nowMs = now()) {
17
+ refill(nowMs);
18
+ if (tokens >= 1) {
19
+ tokens -= 1;
20
+ return true;
21
+ }
22
+ return false;
23
+ },
24
+ retryAfterMs(nowMs = now()) {
25
+ refill(nowMs);
26
+ if (tokens >= 1)
27
+ return 0;
28
+ if (refillPerSec <= 0)
29
+ return Infinity; // never refills
30
+ return Math.ceil(((1 - tokens) / refillPerSec) * 1000);
31
+ },
32
+ };
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "4.8.110",
3
+ "version": "4.8.111",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {