@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.
- package/dist/admin-api.d.ts +82 -7
- package/dist/admin-api.js +80 -17
- package/dist/analytics.d.ts +15 -0
- package/dist/analytics.js +34 -13
- package/dist/model-catalog.js +16 -11
- package/dist/pool.d.ts +23 -0
- package/dist/pool.js +29 -0
- package/dist/proxy.d.ts +1 -0
- package/dist/proxy.js +141 -39
- package/dist/rate-limit.d.ts +23 -0
- package/dist/rate-limit.js +33 -0
- package/package.json +1 -1
package/dist/admin-api.d.ts
CHANGED
|
@@ -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 }
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
/**
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/analytics.d.ts
CHANGED
|
@@ -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
|
|
81
|
-
//
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
const
|
|
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) +
|
package/dist/model-catalog.js
CHANGED
|
@@ -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
|
|
53
|
-
* US-government
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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 = '
|
|
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
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
|
-
//
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
status: '
|
|
923
|
-
|
|
924
|
-
|
|
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 —
|
|
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
|
-
|
|
932
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1215
|
-
|
|
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(
|
|
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.
|
|
1685
|
-
//
|
|
1686
|
-
//
|
|
1687
|
-
//
|
|
1688
|
-
//
|
|
1689
|
-
//
|
|
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
|
|
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.
|
|
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": {
|