@askalf/dario 3.31.15 → 3.31.17
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/accounts.js +15 -10
- package/dist/oauth.js +14 -8
- package/dist/open-browser.d.ts +52 -0
- package/dist/open-browser.js +68 -0
- package/dist/pool.d.ts +48 -4
- package/dist/pool.js +91 -14
- package/dist/proxy.js +28 -10
- package/dist/redact.d.ts +28 -0
- package/dist/redact.js +38 -0
- package/package.json +2 -1
package/dist/accounts.js
CHANGED
|
@@ -23,6 +23,8 @@ import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
|
|
23
23
|
import { createServer } from 'node:http';
|
|
24
24
|
import { detectCCOAuthConfig } from './cc-oauth-detect.js';
|
|
25
25
|
import { loadCredentials } from './oauth.js';
|
|
26
|
+
import { openBrowser } from './open-browser.js';
|
|
27
|
+
import { redactSecrets } from './redact.js';
|
|
26
28
|
const DARIO_DIR = join(homedir(), '.dario');
|
|
27
29
|
const ACCOUNTS_DIR = join(DARIO_DIR, 'accounts');
|
|
28
30
|
/**
|
|
@@ -161,7 +163,10 @@ async function doRefreshAccountToken(creds) {
|
|
|
161
163
|
});
|
|
162
164
|
if (!res.ok) {
|
|
163
165
|
const errBody = await res.text().catch(() => '');
|
|
164
|
-
|
|
166
|
+
// Redact tokens / JWTs / Bearer values before they hit the Error
|
|
167
|
+
// message — defense-in-depth against an upstream that ever echoes a
|
|
168
|
+
// credential into a 4xx body. See src/redact.ts.
|
|
169
|
+
throw new Error(`Refresh failed for ${creds.alias} (${res.status}): ${redactSecrets(errBody.slice(0, 200))}`);
|
|
165
170
|
}
|
|
166
171
|
const data = await res.json();
|
|
167
172
|
const updated = {
|
|
@@ -186,13 +191,10 @@ function generatePKCE() {
|
|
|
186
191
|
const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest());
|
|
187
192
|
return { codeVerifier, codeChallenge };
|
|
188
193
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
: `xdg-open "${url}"`;
|
|
194
|
-
exec(cmd, () => { });
|
|
195
|
-
}
|
|
194
|
+
// `openBrowser` lives in src/open-browser.ts — uses execFile + argv array
|
|
195
|
+
// + URL-protocol allowlist instead of shell interpolation. The previous
|
|
196
|
+
// inline `exec(\`start "" "${url}"\`)` pattern would have shelled out
|
|
197
|
+
// any `&` / `|` / `^` / backtick / `$()` in a URL.
|
|
196
198
|
/**
|
|
197
199
|
* Interactive OAuth flow that adds a new account to the pool. Uses dario's
|
|
198
200
|
* auto-detected CC OAuth config (same scanner the single-account path uses).
|
|
@@ -256,7 +258,7 @@ export async function addAccountViaOAuth(alias) {
|
|
|
256
258
|
});
|
|
257
259
|
if (!tokenRes.ok) {
|
|
258
260
|
const body = await tokenRes.text().catch(() => '');
|
|
259
|
-
throw new Error(`Token exchange failed (${tokenRes.status}): ${body.slice(0, 200)}`);
|
|
261
|
+
throw new Error(`Token exchange failed (${tokenRes.status}): ${redactSecrets(body.slice(0, 200))}`);
|
|
260
262
|
}
|
|
261
263
|
const tokens = await tokenRes.json();
|
|
262
264
|
// Prefer CC identity if installed; otherwise generate fresh IDs.
|
|
@@ -299,7 +301,10 @@ export async function addAccountViaOAuth(alias) {
|
|
|
299
301
|
console.log(` If the browser didn't open, visit:`);
|
|
300
302
|
console.log(` ${authUrl}`);
|
|
301
303
|
console.log();
|
|
302
|
-
|
|
304
|
+
try {
|
|
305
|
+
openBrowser(authUrl);
|
|
306
|
+
}
|
|
307
|
+
catch { /* non-fatal: user has the URL printed above */ }
|
|
303
308
|
});
|
|
304
309
|
server.on('error', (err) => {
|
|
305
310
|
reject(new Error(`Failed to start OAuth callback server: ${err.message}`));
|
package/dist/oauth.js
CHANGED
|
@@ -11,6 +11,7 @@ import { execFile } from 'node:child_process';
|
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
12
12
|
import { homedir, platform } from 'node:os';
|
|
13
13
|
import { detectCCOAuthConfig } from './cc-oauth-detect.js';
|
|
14
|
+
import { redactSecrets } from './redact.js';
|
|
14
15
|
// Manual-flow redirect URI. Anthropic's authorize endpoint special-cases
|
|
15
16
|
// this value (also baked into CC as MANUAL_REDIRECT_URL) to render the
|
|
16
17
|
// authorization code + state on a copy-paste success page instead of
|
|
@@ -255,12 +256,15 @@ export async function startAutoOAuthFlow() {
|
|
|
255
256
|
console.log(' Opening browser to sign in...');
|
|
256
257
|
console.log(` If the browser didn't open, visit: ${authUrl}`);
|
|
257
258
|
console.log('');
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
259
|
+
// Hardened: openBrowser uses execFile + argv array + URL protocol
|
|
260
|
+
// allowlist. Previous inline `exec(\`start "" "${authUrl}"\`)`
|
|
261
|
+
// would have shelled out any `&` / `|` / `^` / backtick / `$()` in
|
|
262
|
+
// a URL — see src/open-browser.ts.
|
|
263
|
+
const { openBrowser } = await import('./open-browser.js');
|
|
264
|
+
try {
|
|
265
|
+
openBrowser(authUrl);
|
|
266
|
+
}
|
|
267
|
+
catch { /* non-fatal: user has the URL printed above */ }
|
|
264
268
|
});
|
|
265
269
|
server.on('error', (err) => {
|
|
266
270
|
reject(new Error(`Failed to start OAuth callback server: ${err.message}`));
|
|
@@ -429,7 +433,9 @@ async function exchangeCodeManual(code, codeVerifier, state) {
|
|
|
429
433
|
});
|
|
430
434
|
if (!res.ok) {
|
|
431
435
|
const body = await res.text().catch(() => '');
|
|
432
|
-
|
|
436
|
+
// See src/redact.ts — strip tokens / JWTs / Bearer values from upstream
|
|
437
|
+
// body before they surface in the Error message.
|
|
438
|
+
throw new Error(`Token exchange failed (HTTP ${res.status}): ${redactSecrets(body.slice(0, 200))}`);
|
|
433
439
|
}
|
|
434
440
|
const data = await res.json();
|
|
435
441
|
const tokens = {
|
|
@@ -490,7 +496,7 @@ async function doRefreshTokens() {
|
|
|
490
496
|
});
|
|
491
497
|
if (!res.ok) {
|
|
492
498
|
const errBody = await res.text().catch(() => '');
|
|
493
|
-
console.error(`[dario] Refresh attempt ${attempt + 1}/3 failed: HTTP ${res.status} — ${errBody.slice(0, 200)}`);
|
|
499
|
+
console.error(`[dario] Refresh attempt ${attempt + 1}/3 failed: HTTP ${res.status} — ${redactSecrets(errBody.slice(0, 200))}`);
|
|
494
500
|
if (res.status === 401 || res.status === 403) {
|
|
495
501
|
throw new Error(`Refresh token rejected (${res.status}). Run \`dario login\` to re-authenticate.`);
|
|
496
502
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe URL → default-browser dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the inline `child_process.exec` template-string patterns that
|
|
5
|
+
* previously lived in `src/oauth.ts` and `src/accounts.ts`. Those used
|
|
6
|
+
* shell interpolation of an external URL (`exec(\`start "" "${url}"\`)`,
|
|
7
|
+
* `exec(\`xdg-open "${url}"\`)`) — defense-in-depth concern: a malicious
|
|
8
|
+
* `DARIO_OAUTH_AUTHORIZE_URL` override, a backdoored `claude` binary
|
|
9
|
+
* smuggling a different `CLAUDE_AI_AUTHORIZE_URL` literal through the
|
|
10
|
+
* cc-oauth-detect scanner, or any future code path that lets a less-
|
|
11
|
+
* trusted source reach this function would inject shell metacharacters
|
|
12
|
+
* (`&`, `|`, `>`, `^`, ``$()``, backtick) and execute arbitrary commands.
|
|
13
|
+
*
|
|
14
|
+
* Hardened path:
|
|
15
|
+
* 1. Parse the URL with WHATWG `URL` — throws on malformed input.
|
|
16
|
+
* 2. Allow only `http:` and `https:` (rejects `file:`, `javascript:`,
|
|
17
|
+
* `vbscript:`, `data:`, custom schemes that route through
|
|
18
|
+
* registered URL handlers).
|
|
19
|
+
* 3. Re-serialize via `parsed.toString()` so any pre-parse oddities
|
|
20
|
+
* get normalized through the URL spec.
|
|
21
|
+
* 4. Spawn the platform's URL-handler binary directly with the URL as
|
|
22
|
+
* a single argv element — no shell, no template interpolation.
|
|
23
|
+
* Windows uses `explorer.exe` (System32 binary, accepts URLs as
|
|
24
|
+
* argv, no cmd hop) instead of `cmd /c start`, which would parse
|
|
25
|
+
* `&`/`|` as command separators after Node's argv → cmd quoting.
|
|
26
|
+
* 5. Errors from the spawned process are swallowed: a failed browser
|
|
27
|
+
* open is non-fatal because every caller also prints the URL to
|
|
28
|
+
* stdout for manual paste.
|
|
29
|
+
*
|
|
30
|
+
* Tests use the `exec` option to inject a stub.
|
|
31
|
+
*/
|
|
32
|
+
import { type ExecFileException } from 'node:child_process';
|
|
33
|
+
export interface OpenBrowserOptions {
|
|
34
|
+
/** Test hook — defaults to node:child_process execFile. */
|
|
35
|
+
exec?: (file: string, args: readonly string[], callback: (error: ExecFileException | null, stdout: string, stderr: string) => void) => void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build the (binary, argv) pair the current platform uses to dispatch a
|
|
39
|
+
* URL to its default browser. Exported for tests.
|
|
40
|
+
*/
|
|
41
|
+
export declare function browserDispatchCommand(url: string, platform?: NodeJS.Platform): {
|
|
42
|
+
bin: string;
|
|
43
|
+
args: string[];
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Open `url` in the user's default browser. See module docstring.
|
|
47
|
+
*
|
|
48
|
+
* @throws if the URL is malformed or has a protocol other than http/https.
|
|
49
|
+
* Browser-launch failures (handler missing, etc.) are swallowed —
|
|
50
|
+
* every caller already prints the URL for manual paste.
|
|
51
|
+
*/
|
|
52
|
+
export declare function openBrowser(url: string, opts?: OpenBrowserOptions): void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe URL → default-browser dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the inline `child_process.exec` template-string patterns that
|
|
5
|
+
* previously lived in `src/oauth.ts` and `src/accounts.ts`. Those used
|
|
6
|
+
* shell interpolation of an external URL (`exec(\`start "" "${url}"\`)`,
|
|
7
|
+
* `exec(\`xdg-open "${url}"\`)`) — defense-in-depth concern: a malicious
|
|
8
|
+
* `DARIO_OAUTH_AUTHORIZE_URL` override, a backdoored `claude` binary
|
|
9
|
+
* smuggling a different `CLAUDE_AI_AUTHORIZE_URL` literal through the
|
|
10
|
+
* cc-oauth-detect scanner, or any future code path that lets a less-
|
|
11
|
+
* trusted source reach this function would inject shell metacharacters
|
|
12
|
+
* (`&`, `|`, `>`, `^`, ``$()``, backtick) and execute arbitrary commands.
|
|
13
|
+
*
|
|
14
|
+
* Hardened path:
|
|
15
|
+
* 1. Parse the URL with WHATWG `URL` — throws on malformed input.
|
|
16
|
+
* 2. Allow only `http:` and `https:` (rejects `file:`, `javascript:`,
|
|
17
|
+
* `vbscript:`, `data:`, custom schemes that route through
|
|
18
|
+
* registered URL handlers).
|
|
19
|
+
* 3. Re-serialize via `parsed.toString()` so any pre-parse oddities
|
|
20
|
+
* get normalized through the URL spec.
|
|
21
|
+
* 4. Spawn the platform's URL-handler binary directly with the URL as
|
|
22
|
+
* a single argv element — no shell, no template interpolation.
|
|
23
|
+
* Windows uses `explorer.exe` (System32 binary, accepts URLs as
|
|
24
|
+
* argv, no cmd hop) instead of `cmd /c start`, which would parse
|
|
25
|
+
* `&`/`|` as command separators after Node's argv → cmd quoting.
|
|
26
|
+
* 5. Errors from the spawned process are swallowed: a failed browser
|
|
27
|
+
* open is non-fatal because every caller also prints the URL to
|
|
28
|
+
* stdout for manual paste.
|
|
29
|
+
*
|
|
30
|
+
* Tests use the `exec` option to inject a stub.
|
|
31
|
+
*/
|
|
32
|
+
import { execFile } from 'node:child_process';
|
|
33
|
+
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
|
|
34
|
+
/**
|
|
35
|
+
* Build the (binary, argv) pair the current platform uses to dispatch a
|
|
36
|
+
* URL to its default browser. Exported for tests.
|
|
37
|
+
*/
|
|
38
|
+
export function browserDispatchCommand(url, platform = process.platform) {
|
|
39
|
+
const parsed = new URL(url);
|
|
40
|
+
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
|
|
41
|
+
throw new Error(`openBrowser: refusing to open URL with protocol "${parsed.protocol}" (only http/https allowed)`);
|
|
42
|
+
}
|
|
43
|
+
const safe = parsed.toString();
|
|
44
|
+
if (platform === 'win32') {
|
|
45
|
+
// explorer.exe accepts a URL as a single argv element and routes it
|
|
46
|
+
// through the registered URL handler. Avoids `cmd /c start "" "URL"`,
|
|
47
|
+
// which re-parses cmd metacharacters even when called via execFile
|
|
48
|
+
// because the cmd builtin runs *inside* cmd's parser, not Node's.
|
|
49
|
+
return { bin: 'explorer.exe', args: [safe] };
|
|
50
|
+
}
|
|
51
|
+
if (platform === 'darwin') {
|
|
52
|
+
return { bin: 'open', args: [safe] };
|
|
53
|
+
}
|
|
54
|
+
// Linux / BSD / other Unix.
|
|
55
|
+
return { bin: 'xdg-open', args: [safe] };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Open `url` in the user's default browser. See module docstring.
|
|
59
|
+
*
|
|
60
|
+
* @throws if the URL is malformed or has a protocol other than http/https.
|
|
61
|
+
* Browser-launch failures (handler missing, etc.) are swallowed —
|
|
62
|
+
* every caller already prints the URL for manual paste.
|
|
63
|
+
*/
|
|
64
|
+
export function openBrowser(url, opts = {}) {
|
|
65
|
+
const { bin, args } = browserDispatchCommand(url);
|
|
66
|
+
const exec = opts.exec ?? execFile;
|
|
67
|
+
exec(bin, args, () => { });
|
|
68
|
+
}
|
package/dist/pool.d.ts
CHANGED
|
@@ -19,6 +19,20 @@ export interface RateLimitSnapshot {
|
|
|
19
19
|
status: string;
|
|
20
20
|
util5h: number;
|
|
21
21
|
util7d: number;
|
|
22
|
+
/**
|
|
23
|
+
* Per-model 7-day utilization buckets — Anthropic carves separate
|
|
24
|
+
* weekly windows for some model families. As of 2026-04-25 the live
|
|
25
|
+
* API emits `anthropic-ratelimit-unified-7d_sonnet-utilization` on
|
|
26
|
+
* Sonnet responses (corresponds to the "Sonnet only" line on the user
|
|
27
|
+
* dashboard); other families do not yet have dedicated buckets but
|
|
28
|
+
* the parser scans the header set generically so any future
|
|
29
|
+
* `7d_<family>` header is captured automatically.
|
|
30
|
+
*
|
|
31
|
+
* Keyed by the family suffix as it arrived on the wire (lowercase,
|
|
32
|
+
* e.g. `sonnet` / `opus` / `haiku`). Empty when no per-model headers
|
|
33
|
+
* were on the response.
|
|
34
|
+
*/
|
|
35
|
+
perModel7d: Record<string, number>;
|
|
22
36
|
overageUtil: number;
|
|
23
37
|
claim: string;
|
|
24
38
|
reset: number;
|
|
@@ -45,6 +59,30 @@ export interface PoolStatus {
|
|
|
45
59
|
}
|
|
46
60
|
/** Parse an Anthropic response's rate-limit headers into a snapshot. */
|
|
47
61
|
export declare function parseRateLimits(headers: Headers): RateLimitSnapshot;
|
|
62
|
+
/**
|
|
63
|
+
* Extract the model family (`opus` / `sonnet` / `haiku`) from a request's
|
|
64
|
+
* model id. Used to look up the per-model 7d bucket in
|
|
65
|
+
* `RateLimitSnapshot.perModel7d` during routing decisions. Returns null
|
|
66
|
+
* for non-Claude models or model ids that don't carry a recognizable
|
|
67
|
+
* family token (those requests just use the unified buckets).
|
|
68
|
+
*
|
|
69
|
+
* Generous on input shape: matches `claude-opus-4-7`, `opus`, `claude-3-7-sonnet-…`,
|
|
70
|
+
* `claude-haiku-4-5`, anything containing the family token. Lowercase-normalized
|
|
71
|
+
* so it pairs cleanly with `parseRateLimits`'s lowercase family keys.
|
|
72
|
+
*/
|
|
73
|
+
export declare function modelFamily(modelId: string | null | undefined): string | null;
|
|
74
|
+
/**
|
|
75
|
+
* Compute headroom for a single account given its rate-limit snapshot.
|
|
76
|
+
* Headroom is the slack between the most-saturated relevant bucket and
|
|
77
|
+
* full utilization: `1 - max(util5h, util7d, util_per_model_if_known)`.
|
|
78
|
+
*
|
|
79
|
+
* When `family` is supplied AND the snapshot has a corresponding per-
|
|
80
|
+
* model 7d bucket, that bucket is included in the max. When the family
|
|
81
|
+
* isn't represented in the snapshot (e.g. account hasn't seen a Sonnet
|
|
82
|
+
* request yet so `7d_sonnet` is unknown), headroom is computed from the
|
|
83
|
+
* unified buckets only — best-effort, populated on the next response.
|
|
84
|
+
*/
|
|
85
|
+
export declare function computeHeadroom(snapshot: RateLimitSnapshot, family?: string | null): number;
|
|
48
86
|
export declare class AccountPool {
|
|
49
87
|
private accounts;
|
|
50
88
|
private queue;
|
|
@@ -61,8 +99,14 @@ export declare class AccountPool {
|
|
|
61
99
|
}): void;
|
|
62
100
|
remove(alias: string): boolean;
|
|
63
101
|
get size(): number;
|
|
64
|
-
/**
|
|
65
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Select the best account for the next request. `family` (when supplied)
|
|
104
|
+
* is the request's model family (`opus` / `sonnet` / `haiku`); when
|
|
105
|
+
* present and the account has a matching per-model 7d bucket, that
|
|
106
|
+
* bucket joins the headroom max. Family-less calls fall back to the
|
|
107
|
+
* unified-buckets-only headroom — same behavior as before this PR.
|
|
108
|
+
*/
|
|
109
|
+
select(family?: string | null): PoolAccount | null;
|
|
66
110
|
/**
|
|
67
111
|
* Select with session stickiness. If `stickyKey` is already bound to a
|
|
68
112
|
* healthy account (not rejected, token not near expiry, headroom > 2%),
|
|
@@ -79,7 +123,7 @@ export declare class AccountPool {
|
|
|
79
123
|
*
|
|
80
124
|
* Also performs lazy cleanup of expired bindings (TTL or size cap).
|
|
81
125
|
*/
|
|
82
|
-
selectSticky(stickyKey: string | null): PoolAccount | null;
|
|
126
|
+
selectSticky(stickyKey: string | null, family?: string | null): PoolAccount | null;
|
|
83
127
|
/**
|
|
84
128
|
* Rebind a sticky key to a different account — called by proxy after an
|
|
85
129
|
* in-request 429 failover moves to the next-best account. Without this
|
|
@@ -99,7 +143,7 @@ export declare class AccountPool {
|
|
|
99
143
|
/** Test/inspection helper — current alias bound to a key, or null. */
|
|
100
144
|
stickyAliasFor(stickyKey: string): string | null;
|
|
101
145
|
/** Select the next-best account, excluding the given set of aliases. */
|
|
102
|
-
selectExcluding(excluded: Set<string
|
|
146
|
+
selectExcluding(excluded: Set<string>, family?: string | null): PoolAccount | null;
|
|
103
147
|
updateRateLimits(alias: string, snapshot: RateLimitSnapshot): void;
|
|
104
148
|
markRejected(alias: string, snapshot: RateLimitSnapshot): void;
|
|
105
149
|
updateTokens(alias: string, accessToken: string, refreshToken: string, expiresAt: number): void;
|
package/dist/pool.js
CHANGED
|
@@ -28,19 +28,44 @@ export const EMPTY_SNAPSHOT = {
|
|
|
28
28
|
status: 'unknown',
|
|
29
29
|
util5h: 0,
|
|
30
30
|
util7d: 0,
|
|
31
|
+
perModel7d: {},
|
|
31
32
|
overageUtil: 0,
|
|
32
33
|
claim: 'unknown',
|
|
33
34
|
reset: 0,
|
|
34
35
|
fallbackPct: 0,
|
|
35
36
|
updatedAt: 0,
|
|
36
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* Match `anthropic-ratelimit-unified-7d_<family>-utilization`. Generic on
|
|
40
|
+
* `<family>` so a future `7d_opus` / `7d_haiku` (or anything Anthropic
|
|
41
|
+
* adds without notice) is captured automatically. The family is
|
|
42
|
+
* normalized to lowercase to match `modelFamily()` output.
|
|
43
|
+
*/
|
|
44
|
+
const PER_MODEL_7D_HEADER = /^anthropic-ratelimit-unified-7d_([a-z0-9-]+)-utilization$/i;
|
|
37
45
|
/** Parse an Anthropic response's rate-limit headers into a snapshot. */
|
|
38
46
|
export function parseRateLimits(headers) {
|
|
39
47
|
const get = (key) => headers.get(`anthropic-ratelimit-unified-${key}`) ?? '';
|
|
48
|
+
const perModel7d = {};
|
|
49
|
+
// Iterate the full header set — `headers.get` only retrieves known
|
|
50
|
+
// keys, but Anthropic can add new `7d_<family>-utilization` shapes
|
|
51
|
+
// unannounced. Scanning the iterator means the parser is automatically
|
|
52
|
+
// forward-compatible. Real `Headers` instances and test-side mocks
|
|
53
|
+
// (which implement `.entries()` but not direct iteration) both work
|
|
54
|
+
// through the explicit `.entries()` call.
|
|
55
|
+
const entries = (typeof headers.entries === 'function')
|
|
56
|
+
? headers.entries()
|
|
57
|
+
: headers;
|
|
58
|
+
for (const [k, v] of entries) {
|
|
59
|
+
const m = k.match(PER_MODEL_7D_HEADER);
|
|
60
|
+
if (m && m[1]) {
|
|
61
|
+
perModel7d[m[1].toLowerCase()] = parseFloat(v) || 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
40
64
|
return {
|
|
41
65
|
status: get('status') || 'unknown',
|
|
42
66
|
util5h: parseFloat(get('5h-utilization')) || 0,
|
|
43
67
|
util7d: parseFloat(get('7d-utilization')) || 0,
|
|
68
|
+
perModel7d,
|
|
44
69
|
overageUtil: parseFloat(get('overage-utilization')) || 0,
|
|
45
70
|
claim: get('representative-claim') || 'unknown',
|
|
46
71
|
reset: parseInt(get('reset')) || 0,
|
|
@@ -48,6 +73,49 @@ export function parseRateLimits(headers) {
|
|
|
48
73
|
updatedAt: Date.now(),
|
|
49
74
|
};
|
|
50
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Extract the model family (`opus` / `sonnet` / `haiku`) from a request's
|
|
78
|
+
* model id. Used to look up the per-model 7d bucket in
|
|
79
|
+
* `RateLimitSnapshot.perModel7d` during routing decisions. Returns null
|
|
80
|
+
* for non-Claude models or model ids that don't carry a recognizable
|
|
81
|
+
* family token (those requests just use the unified buckets).
|
|
82
|
+
*
|
|
83
|
+
* Generous on input shape: matches `claude-opus-4-7`, `opus`, `claude-3-7-sonnet-…`,
|
|
84
|
+
* `claude-haiku-4-5`, anything containing the family token. Lowercase-normalized
|
|
85
|
+
* so it pairs cleanly with `parseRateLimits`'s lowercase family keys.
|
|
86
|
+
*/
|
|
87
|
+
export function modelFamily(modelId) {
|
|
88
|
+
if (!modelId)
|
|
89
|
+
return null;
|
|
90
|
+
const m = modelId.toLowerCase();
|
|
91
|
+
if (m.includes('opus'))
|
|
92
|
+
return 'opus';
|
|
93
|
+
if (m.includes('sonnet'))
|
|
94
|
+
return 'sonnet';
|
|
95
|
+
if (m.includes('haiku'))
|
|
96
|
+
return 'haiku';
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Compute headroom for a single account given its rate-limit snapshot.
|
|
101
|
+
* Headroom is the slack between the most-saturated relevant bucket and
|
|
102
|
+
* full utilization: `1 - max(util5h, util7d, util_per_model_if_known)`.
|
|
103
|
+
*
|
|
104
|
+
* When `family` is supplied AND the snapshot has a corresponding per-
|
|
105
|
+
* model 7d bucket, that bucket is included in the max. When the family
|
|
106
|
+
* isn't represented in the snapshot (e.g. account hasn't seen a Sonnet
|
|
107
|
+
* request yet so `7d_sonnet` is unknown), headroom is computed from the
|
|
108
|
+
* unified buckets only — best-effort, populated on the next response.
|
|
109
|
+
*/
|
|
110
|
+
export function computeHeadroom(snapshot, family) {
|
|
111
|
+
const utils = [snapshot.util5h, snapshot.util7d];
|
|
112
|
+
if (family) {
|
|
113
|
+
const perModel = snapshot.perModel7d[family];
|
|
114
|
+
if (perModel !== undefined)
|
|
115
|
+
utils.push(perModel);
|
|
116
|
+
}
|
|
117
|
+
return 1 - Math.max(...utils);
|
|
118
|
+
}
|
|
51
119
|
const STICKY_TTL_MS = 6 * 60 * 60 * 1000; // 6h
|
|
52
120
|
const STICKY_MAX_ENTRIES = 2_000; // lazy cleanup cap
|
|
53
121
|
/**
|
|
@@ -87,8 +155,14 @@ export class AccountPool {
|
|
|
87
155
|
get size() {
|
|
88
156
|
return this.accounts.size;
|
|
89
157
|
}
|
|
90
|
-
/**
|
|
91
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Select the best account for the next request. `family` (when supplied)
|
|
160
|
+
* is the request's model family (`opus` / `sonnet` / `haiku`); when
|
|
161
|
+
* present and the account has a matching per-model 7d bucket, that
|
|
162
|
+
* bucket joins the headroom max. Family-less calls fall back to the
|
|
163
|
+
* unified-buckets-only headroom — same behavior as before this PR.
|
|
164
|
+
*/
|
|
165
|
+
select(family) {
|
|
92
166
|
if (this.accounts.size === 0)
|
|
93
167
|
return null;
|
|
94
168
|
const now = Date.now();
|
|
@@ -97,8 +171,8 @@ export class AccountPool {
|
|
|
97
171
|
a.expiresAt > now + 30_000);
|
|
98
172
|
if (eligible.length > 0) {
|
|
99
173
|
return eligible.reduce((best, curr) => {
|
|
100
|
-
const bestHeadroom =
|
|
101
|
-
const currHeadroom =
|
|
174
|
+
const bestHeadroom = computeHeadroom(best.rateLimit, family);
|
|
175
|
+
const currHeadroom = computeHeadroom(curr.rateLimit, family);
|
|
102
176
|
return currHeadroom > bestHeadroom ? curr : best;
|
|
103
177
|
});
|
|
104
178
|
}
|
|
@@ -126,9 +200,9 @@ export class AccountPool {
|
|
|
126
200
|
*
|
|
127
201
|
* Also performs lazy cleanup of expired bindings (TTL or size cap).
|
|
128
202
|
*/
|
|
129
|
-
selectSticky(stickyKey) {
|
|
203
|
+
selectSticky(stickyKey, family) {
|
|
130
204
|
if (!stickyKey)
|
|
131
|
-
return this.select();
|
|
205
|
+
return this.select(family);
|
|
132
206
|
this.cleanupSticky();
|
|
133
207
|
const binding = this.sticky.get(stickyKey);
|
|
134
208
|
if (binding) {
|
|
@@ -137,11 +211,11 @@ export class AccountPool {
|
|
|
137
211
|
if (bound
|
|
138
212
|
&& bound.rateLimit.status !== 'rejected'
|
|
139
213
|
&& bound.expiresAt > now + 30_000
|
|
140
|
-
&& (
|
|
214
|
+
&& computeHeadroom(bound.rateLimit, family) > POOL_HEADROOM_FLOOR) {
|
|
141
215
|
return bound;
|
|
142
216
|
}
|
|
143
217
|
}
|
|
144
|
-
const picked = this.select();
|
|
218
|
+
const picked = this.select(family);
|
|
145
219
|
if (picked) {
|
|
146
220
|
this.sticky.set(stickyKey, { alias: picked.alias, boundAt: Date.now() });
|
|
147
221
|
}
|
|
@@ -189,7 +263,7 @@ export class AccountPool {
|
|
|
189
263
|
return this.sticky.get(stickyKey)?.alias ?? null;
|
|
190
264
|
}
|
|
191
265
|
/** Select the next-best account, excluding the given set of aliases. */
|
|
192
|
-
selectExcluding(excluded) {
|
|
266
|
+
selectExcluding(excluded, family) {
|
|
193
267
|
if (this.accounts.size <= 1)
|
|
194
268
|
return null;
|
|
195
269
|
const now = Date.now();
|
|
@@ -198,8 +272,8 @@ export class AccountPool {
|
|
|
198
272
|
a.expiresAt > now + 30_000);
|
|
199
273
|
if (eligible.length > 0) {
|
|
200
274
|
return eligible.reduce((best, curr) => {
|
|
201
|
-
const bestHeadroom =
|
|
202
|
-
const currHeadroom =
|
|
275
|
+
const bestHeadroom = computeHeadroom(best.rateLimit, family);
|
|
276
|
+
const currHeadroom = computeHeadroom(curr.rateLimit, family);
|
|
203
277
|
return currHeadroom > bestHeadroom ? curr : best;
|
|
204
278
|
});
|
|
205
279
|
}
|
|
@@ -240,7 +314,10 @@ export class AccountPool {
|
|
|
240
314
|
const now = Date.now();
|
|
241
315
|
const healthy = all.filter(a => a.rateLimit.status !== 'rejected' &&
|
|
242
316
|
a.expiresAt > now + 30_000);
|
|
243
|
-
|
|
317
|
+
// Status is a pool-wide aggregate; family-agnostic. Per-model
|
|
318
|
+
// headroom is request-context-specific and only meaningful at
|
|
319
|
+
// select() time.
|
|
320
|
+
const headrooms = all.map(a => computeHeadroom(a.rateLimit));
|
|
244
321
|
const avgHeadroom = headrooms.length > 0 ? headrooms.reduce((a, b) => a + b, 0) / headrooms.length : 0;
|
|
245
322
|
const best = this.select();
|
|
246
323
|
return {
|
|
@@ -260,7 +337,7 @@ export class AccountPool {
|
|
|
260
337
|
async waitForAccount() {
|
|
261
338
|
const immediate = this.select();
|
|
262
339
|
if (immediate) {
|
|
263
|
-
const headroom =
|
|
340
|
+
const headroom = computeHeadroom(immediate.rateLimit);
|
|
264
341
|
if (headroom > POOL_HEADROOM_FLOOR)
|
|
265
342
|
return immediate;
|
|
266
343
|
}
|
|
@@ -303,7 +380,7 @@ export class AccountPool {
|
|
|
303
380
|
const account = this.select();
|
|
304
381
|
if (!account)
|
|
305
382
|
break;
|
|
306
|
-
const headroom =
|
|
383
|
+
const headroom = computeHeadroom(account.rateLimit);
|
|
307
384
|
if (headroom <= POOL_HEADROOM_FLOOR)
|
|
308
385
|
break;
|
|
309
386
|
const entry = this.queue.shift();
|
package/dist/proxy.js
CHANGED
|
@@ -8,11 +8,12 @@ import { arch, platform } from 'node:process';
|
|
|
8
8
|
import { getAccessToken, getStatus } from './oauth.js';
|
|
9
9
|
import { buildCCRequest, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, CC_TEMPLATE } from './cc-template.js';
|
|
10
10
|
import { describeTemplate, detectDrift, checkCCCompat } from './live-fingerprint.js';
|
|
11
|
-
import { AccountPool, computeStickyKey, parseRateLimits } from './pool.js';
|
|
11
|
+
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily } from './pool.js';
|
|
12
12
|
import { Analytics, billingBucketFromClaim } from './analytics.js';
|
|
13
13
|
import { loadAllAccounts, loadAccount, refreshAccountToken } from './accounts.js';
|
|
14
14
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
15
15
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
|
|
16
|
+
import { redactSecrets } from './redact.js';
|
|
16
17
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
17
18
|
const DEFAULT_PORT = 3456;
|
|
18
19
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — generous for large prompts, prevents abuse
|
|
@@ -345,12 +346,10 @@ function translateStreamChunk(line) {
|
|
|
345
346
|
}
|
|
346
347
|
const OPENAI_MODELS_LIST = { object: 'list', data: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'].map(id => ({ id, object: 'model', created: 1700000000, owned_by: 'anthropic' })) };
|
|
347
348
|
export function sanitizeError(err) {
|
|
348
|
-
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
.replace(/eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, '[REDACTED_JWT]')
|
|
353
|
-
.replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]');
|
|
349
|
+
// Pattern set lives in src/redact.ts so OAuth call sites can run the
|
|
350
|
+
// same redaction directly on response-body strings without importing
|
|
351
|
+
// proxy (which imports oauth — would circle).
|
|
352
|
+
return redactSecrets(err instanceof Error ? err.message : String(err));
|
|
354
353
|
}
|
|
355
354
|
/**
|
|
356
355
|
* API-key auth via DARIO_API_KEY (x-api-key or Authorization: Bearer).
|
|
@@ -461,6 +460,11 @@ export async function startProxy(opts = {}) {
|
|
|
461
460
|
// Single-account dario keeps its existing code path unchanged.
|
|
462
461
|
const accountsList = await loadAllAccounts();
|
|
463
462
|
const pool = accountsList.length >= 2 ? new AccountPool() : null;
|
|
463
|
+
// Per-model rate-limit bucket families seen during this proxy run. First-
|
|
464
|
+
// sight is logged once when verbose so a new Anthropic bucket (e.g. an
|
|
465
|
+
// eventual `7d_opus`) doesn't slip past unnoticed. Pure observability —
|
|
466
|
+
// routing already handles unknown families generically.
|
|
467
|
+
const seenPerModelBuckets = new Set();
|
|
464
468
|
const analytics = pool ? new Analytics() : null;
|
|
465
469
|
let status;
|
|
466
470
|
if (pool) {
|
|
@@ -965,7 +969,7 @@ export async function startProxy(opts = {}) {
|
|
|
965
969
|
// Rotating off mid-session costs cache-create on every turn.
|
|
966
970
|
stickyKey = computeStickyKey(userMsg);
|
|
967
971
|
if (pool && stickyKey) {
|
|
968
|
-
const preferred = pool.selectSticky(stickyKey);
|
|
972
|
+
const preferred = pool.selectSticky(stickyKey, modelFamily(requestModel));
|
|
969
973
|
if (preferred && preferred.alias !== poolAccount?.alias) {
|
|
970
974
|
poolAccount = preferred;
|
|
971
975
|
accessToken = preferred.accessToken;
|
|
@@ -1186,6 +1190,20 @@ export async function startProxy(opts = {}) {
|
|
|
1186
1190
|
else {
|
|
1187
1191
|
pool.updateRateLimits(poolAccount.alias, snapshot);
|
|
1188
1192
|
}
|
|
1193
|
+
// First-sight detector for per-model rate-limit buckets. Anthropic
|
|
1194
|
+
// ships these unannounced — e.g. `7d_sonnet-utilization` appeared
|
|
1195
|
+
// around 2026-04-25 — and verbose-mode users want a heads-up the
|
|
1196
|
+
// first time a new family shows up so they can decide whether to
|
|
1197
|
+
// bump dario's expectations. Pure logging; the routing path
|
|
1198
|
+
// already handles arbitrary family keys (see pool.computeHeadroom).
|
|
1199
|
+
for (const family of Object.keys(snapshot.perModel7d)) {
|
|
1200
|
+
if (!seenPerModelBuckets.has(family)) {
|
|
1201
|
+
seenPerModelBuckets.add(family);
|
|
1202
|
+
if (verbose) {
|
|
1203
|
+
console.log(`[dario] new per-model rate-limit bucket observed: 7d_${family} (util=${snapshot.perModel7d[family]?.toFixed(2)})`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1189
1207
|
}
|
|
1190
1208
|
// Auto-retry without context-1m if it triggers a long-context billing error.
|
|
1191
1209
|
// Anthropic returns this as either 400 ("long context beta is not yet available
|
|
@@ -1288,7 +1306,7 @@ export async function startProxy(opts = {}) {
|
|
|
1288
1306
|
else if (upstream.status === 429) {
|
|
1289
1307
|
// Not a context-1m issue — try pool failover before surfacing to client
|
|
1290
1308
|
if (pool && poolAccount) {
|
|
1291
|
-
const nextAccount = pool.selectExcluding(triedAliases);
|
|
1309
|
+
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
1292
1310
|
if (nextAccount) {
|
|
1293
1311
|
triedAliases.add(nextAccount.alias);
|
|
1294
1312
|
poolAccount = nextAccount;
|
|
@@ -1347,7 +1365,7 @@ export async function startProxy(opts = {}) {
|
|
|
1347
1365
|
if (upstream.status === 429) {
|
|
1348
1366
|
// Try pool failover before surfacing to client
|
|
1349
1367
|
if (pool && poolAccount) {
|
|
1350
|
-
const nextAccount = pool.selectExcluding(triedAliases);
|
|
1368
|
+
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
1351
1369
|
if (nextAccount) {
|
|
1352
1370
|
triedAliases.add(nextAccount.alias);
|
|
1353
1371
|
poolAccount = nextAccount;
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for free-form strings emitted by dario.
|
|
3
|
+
*
|
|
4
|
+
* Used wherever an externally-sourced string (an upstream HTTP response
|
|
5
|
+
* body, an exception message, a verbose log line) might transit through
|
|
6
|
+
* to a place a user can see (stderr, an Error message, a JSON response).
|
|
7
|
+
* Even though Anthropic's documented API is not known to echo tokens in
|
|
8
|
+
* error responses, defense-in-depth: a future API change, a CDN error
|
|
9
|
+
* page that captures the request headers, or an intermediary's debug
|
|
10
|
+
* dump could surface a token, and we'd rather redact in transit than
|
|
11
|
+
* audit every call site for novel leak shapes.
|
|
12
|
+
*
|
|
13
|
+
* Patterns match formats actually seen in the Anthropic ecosystem:
|
|
14
|
+
* - `sk-ant-…` — long-lived API keys
|
|
15
|
+
* - JWT triple — OAuth access tokens (`eyJhdr.eyJpyld.sig`)
|
|
16
|
+
* - `Bearer <…>` — auth headers, raw or quoted
|
|
17
|
+
*
|
|
18
|
+
* Re-exported by `proxy.ts:sanitizeError` so the proxy's existing leak-
|
|
19
|
+
* shield benefits from any new patterns added here, and consumed
|
|
20
|
+
* directly by the OAuth code paths in `oauth.ts` / `accounts.ts` for
|
|
21
|
+
* sanitizing upstream error bodies before they hit `throw new Error`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const SECRET_PATTERNS: ReadonlyArray<readonly [RegExp, string]>;
|
|
24
|
+
/**
|
|
25
|
+
* Apply every redaction pattern to a string. Idempotent — running a
|
|
26
|
+
* pre-redacted string through this is a no-op.
|
|
27
|
+
*/
|
|
28
|
+
export declare function redactSecrets(s: string): string;
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for free-form strings emitted by dario.
|
|
3
|
+
*
|
|
4
|
+
* Used wherever an externally-sourced string (an upstream HTTP response
|
|
5
|
+
* body, an exception message, a verbose log line) might transit through
|
|
6
|
+
* to a place a user can see (stderr, an Error message, a JSON response).
|
|
7
|
+
* Even though Anthropic's documented API is not known to echo tokens in
|
|
8
|
+
* error responses, defense-in-depth: a future API change, a CDN error
|
|
9
|
+
* page that captures the request headers, or an intermediary's debug
|
|
10
|
+
* dump could surface a token, and we'd rather redact in transit than
|
|
11
|
+
* audit every call site for novel leak shapes.
|
|
12
|
+
*
|
|
13
|
+
* Patterns match formats actually seen in the Anthropic ecosystem:
|
|
14
|
+
* - `sk-ant-…` — long-lived API keys
|
|
15
|
+
* - JWT triple — OAuth access tokens (`eyJhdr.eyJpyld.sig`)
|
|
16
|
+
* - `Bearer <…>` — auth headers, raw or quoted
|
|
17
|
+
*
|
|
18
|
+
* Re-exported by `proxy.ts:sanitizeError` so the proxy's existing leak-
|
|
19
|
+
* shield benefits from any new patterns added here, and consumed
|
|
20
|
+
* directly by the OAuth code paths in `oauth.ts` / `accounts.ts` for
|
|
21
|
+
* sanitizing upstream error bodies before they hit `throw new Error`.
|
|
22
|
+
*/
|
|
23
|
+
export const SECRET_PATTERNS = [
|
|
24
|
+
[/sk-ant-[a-zA-Z0-9_-]+/g, '[REDACTED]'],
|
|
25
|
+
[/eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, '[REDACTED_JWT]'],
|
|
26
|
+
[/Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]'],
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Apply every redaction pattern to a string. Idempotent — running a
|
|
30
|
+
* pre-redacted string through this is a no-op.
|
|
31
|
+
*/
|
|
32
|
+
export function redactSecrets(s) {
|
|
33
|
+
let out = s;
|
|
34
|
+
for (const [pat, repl] of SECRET_PATTERNS) {
|
|
35
|
+
out = out.replace(pat, repl);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "3.31.
|
|
3
|
+
"version": "3.31.17",
|
|
4
4
|
"description": "A local LLM router. One endpoint, every provider — Claude subscriptions, OpenAI, OpenRouter, Groq, local LiteLLM, any OpenAI-compat endpoint — your tools don't need to change.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"start": "node dist/cli.js",
|
|
29
29
|
"dev": "tsx src/cli.ts",
|
|
30
30
|
"e2e": "node test/e2e.mjs",
|
|
31
|
+
"stress": "node test/stress.mjs",
|
|
31
32
|
"compat": "node test/compat.mjs",
|
|
32
33
|
"lint:pkg": "node scripts/check-package-json.mjs",
|
|
33
34
|
"drift:sdk": "node scripts/check-sdk-drift.mjs",
|