@askalf/dario 3.4.6 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -16
- package/dist/accounts.d.ts +23 -0
- package/dist/accounts.js +253 -0
- package/dist/analytics.d.ts +99 -0
- package/dist/analytics.js +198 -0
- package/dist/cli.js +113 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/pool.d.ts +68 -0
- package/dist/pool.js +212 -0
- package/dist/proxy.js +142 -10
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -7,6 +7,9 @@ import { homedir } from 'node:os';
|
|
|
7
7
|
import { arch, platform } from 'node:process';
|
|
8
8
|
import { getAccessToken, getStatus } from './oauth.js';
|
|
9
9
|
import { buildCCRequest, reverseMapResponse } from './cc-template.js';
|
|
10
|
+
import { AccountPool, parseRateLimits } from './pool.js';
|
|
11
|
+
import { Analytics } from './analytics.js';
|
|
12
|
+
import { loadAllAccounts, loadAccount, refreshAccountToken } from './accounts.js';
|
|
10
13
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
11
14
|
const DEFAULT_PORT = 3456;
|
|
12
15
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — generous for large prompts, prevents abuse
|
|
@@ -321,11 +324,59 @@ export async function startProxy(opts = {}) {
|
|
|
321
324
|
const host = opts.host ?? process.env.DARIO_HOST ?? DEFAULT_HOST;
|
|
322
325
|
const verbose = opts.verbose ?? false;
|
|
323
326
|
const passthrough = opts.passthrough ?? false;
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
327
|
+
// Multi-account pool — activated when ~/.dario/accounts/ has 2+ entries.
|
|
328
|
+
// Single-account dario keeps its existing code path unchanged.
|
|
329
|
+
const accountsList = await loadAllAccounts();
|
|
330
|
+
const pool = accountsList.length >= 2 ? new AccountPool() : null;
|
|
331
|
+
const analytics = pool ? new Analytics() : null;
|
|
332
|
+
let status;
|
|
333
|
+
if (pool) {
|
|
334
|
+
for (const acc of accountsList) {
|
|
335
|
+
pool.add(acc.alias, {
|
|
336
|
+
accessToken: acc.accessToken,
|
|
337
|
+
refreshToken: acc.refreshToken,
|
|
338
|
+
expiresAt: acc.expiresAt,
|
|
339
|
+
deviceId: acc.deviceId,
|
|
340
|
+
accountUuid: acc.accountUuid,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
console.log(` Pool mode: ${accountsList.length} accounts loaded`);
|
|
344
|
+
// Background refresh — keep every account's token fresh without blocking requests
|
|
345
|
+
const refreshInterval = setInterval(async () => {
|
|
346
|
+
for (const acc of pool.all()) {
|
|
347
|
+
if (acc.expiresAt < Date.now() + 45 * 60 * 1000) {
|
|
348
|
+
try {
|
|
349
|
+
const saved = await loadAccount(acc.alias);
|
|
350
|
+
if (!saved)
|
|
351
|
+
continue;
|
|
352
|
+
const refreshed = await refreshAccountToken(saved);
|
|
353
|
+
pool.updateTokens(acc.alias, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
console.error(`[dario] Background refresh failed for ${acc.alias}: ${err instanceof Error ? err.message : err}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}, 15 * 60 * 1000);
|
|
361
|
+
refreshInterval.unref();
|
|
362
|
+
// Pool mode doesn't check single-account status — compute a placeholder
|
|
363
|
+
// for the startup banner using the pool's earliest expiry.
|
|
364
|
+
const earliest = Math.min(...pool.all().map(a => a.expiresAt));
|
|
365
|
+
const msLeft = Math.max(0, earliest - Date.now());
|
|
366
|
+
status = {
|
|
367
|
+
authenticated: true,
|
|
368
|
+
status: 'healthy',
|
|
369
|
+
expiresAt: earliest,
|
|
370
|
+
expiresIn: `${Math.floor(msLeft / 3600000)}h ${Math.floor((msLeft % 3600000) / 60000)}m`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
// Single-account mode — existing auth check
|
|
375
|
+
status = await getStatus();
|
|
376
|
+
if (!status.authenticated) {
|
|
377
|
+
console.error('[dario] Not authenticated. Run `dario login` first.');
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
329
380
|
}
|
|
330
381
|
const cliVersion = detectCliVersion();
|
|
331
382
|
const modelOverride = opts.model ? (MODEL_ALIASES[opts.model] ?? opts.model) : null;
|
|
@@ -433,6 +484,39 @@ export async function startProxy(opts = {}) {
|
|
|
433
484
|
res.end(JSON.stringify(s));
|
|
434
485
|
return;
|
|
435
486
|
}
|
|
487
|
+
// Pool status endpoint — shows loaded accounts, headroom, and the
|
|
488
|
+
// account that would be selected next. Read-only; mutation flows through
|
|
489
|
+
// the `dario accounts` CLI, not HTTP.
|
|
490
|
+
if (urlPath === '/accounts' && req.method === 'GET') {
|
|
491
|
+
if (!pool) {
|
|
492
|
+
res.writeHead(200, JSON_HEADERS);
|
|
493
|
+
res.end(JSON.stringify({ mode: 'single-account', accounts: 0 }));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const accounts = pool.all().map(a => ({
|
|
497
|
+
alias: a.alias,
|
|
498
|
+
util5h: a.rateLimit.util5h,
|
|
499
|
+
util7d: a.rateLimit.util7d,
|
|
500
|
+
claim: a.rateLimit.claim,
|
|
501
|
+
status: a.rateLimit.status,
|
|
502
|
+
requestCount: a.requestCount,
|
|
503
|
+
expiresInMs: Math.max(0, a.expiresAt - Date.now()),
|
|
504
|
+
}));
|
|
505
|
+
res.writeHead(200, JSON_HEADERS);
|
|
506
|
+
res.end(JSON.stringify({ mode: 'pool', ...pool.status(), accounts }));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
// Analytics endpoint — request history + burn-rate summary (pool mode only).
|
|
510
|
+
if (urlPath === '/analytics' && req.method === 'GET') {
|
|
511
|
+
if (!analytics) {
|
|
512
|
+
res.writeHead(200, JSON_HEADERS);
|
|
513
|
+
res.end(JSON.stringify({ mode: 'single-account', note: 'Analytics are only collected in pool mode.' }));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
res.writeHead(200, JSON_HEADERS);
|
|
517
|
+
res.end(JSON.stringify(analytics.summary()));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
436
520
|
if (urlPath === '/v1/models' && req.method === 'GET') {
|
|
437
521
|
requestCount++;
|
|
438
522
|
res.writeHead(200, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
|
|
@@ -465,7 +549,26 @@ export async function startProxy(opts = {}) {
|
|
|
465
549
|
let onClientClose = null;
|
|
466
550
|
let upstreamAbortReason = null;
|
|
467
551
|
try {
|
|
468
|
-
|
|
552
|
+
// Pool mode: select an account by headroom. Single-account mode:
|
|
553
|
+
// fall through to getAccessToken() exactly as before. Request-path
|
|
554
|
+
// 429 failover (retry with the next-best account before returning a
|
|
555
|
+
// rate-limit error to the client) lands in v3.5.1 — this release
|
|
556
|
+
// ships the pool scaffolding and headroom-aware selection across
|
|
557
|
+
// requests, not within a single 429 retry.
|
|
558
|
+
let poolAccount = null;
|
|
559
|
+
let accessToken;
|
|
560
|
+
if (pool) {
|
|
561
|
+
poolAccount = pool.select();
|
|
562
|
+
if (!poolAccount) {
|
|
563
|
+
res.writeHead(503, JSON_HEADERS);
|
|
564
|
+
res.end(JSON.stringify({ error: 'No accounts available in pool' }));
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
accessToken = poolAccount.accessToken;
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
accessToken = await getAccessToken();
|
|
571
|
+
}
|
|
469
572
|
// Read request body with size limit and timeout (prevents slow-loris)
|
|
470
573
|
const chunks = [];
|
|
471
574
|
let totalBytes = 0;
|
|
@@ -511,7 +614,10 @@ export async function startProxy(opts = {}) {
|
|
|
511
614
|
const fullVersion = `${cliVersion}.${buildTag}`;
|
|
512
615
|
const billingTag = `x-anthropic-billing-header: cc_version=${fullVersion}; cc_entrypoint=cli; cch=${cch};`;
|
|
513
616
|
const CACHE_1H = { type: 'ephemeral', ttl: '1h' };
|
|
514
|
-
const
|
|
617
|
+
const bodyIdentity = poolAccount
|
|
618
|
+
? poolAccount.identity
|
|
619
|
+
: { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId: SESSION_ID };
|
|
620
|
+
const { body: ccBody, toolMap } = buildCCRequest(r, billingTag, CACHE_1H, bodyIdentity, { preserveTools: opts.preserveTools ?? false });
|
|
515
621
|
// Store tool map for response reverse-mapping
|
|
516
622
|
ccToolMap = toolMap;
|
|
517
623
|
// Replace request body entirely with CC template
|
|
@@ -555,12 +661,16 @@ export async function startProxy(opts = {}) {
|
|
|
555
661
|
await new Promise(r => setTimeout(r, MIN_REQUEST_INTERVAL_MS - elapsed));
|
|
556
662
|
}
|
|
557
663
|
lastRequestTime = Date.now();
|
|
558
|
-
// Rotate session ID per request — fresh UUID avoids persistent-session fingerprinting
|
|
559
|
-
|
|
664
|
+
// Rotate session ID per request — fresh UUID avoids persistent-session fingerprinting.
|
|
665
|
+
// Pool mode uses the per-account identity.sessionId which is stable across
|
|
666
|
+
// a given account's lifetime; single-account mode rotates per request.
|
|
667
|
+
if (!poolAccount)
|
|
668
|
+
SESSION_ID = randomUUID();
|
|
669
|
+
const outboundSessionId = poolAccount ? poolAccount.identity.sessionId : SESSION_ID;
|
|
560
670
|
const headers = {
|
|
561
671
|
...staticHeaders,
|
|
562
672
|
'Authorization': `Bearer ${accessToken}`,
|
|
563
|
-
'x-claude-code-session-id':
|
|
673
|
+
'x-claude-code-session-id': outboundSessionId,
|
|
564
674
|
'anthropic-version': passthrough ? (req.headers['anthropic-version'] || '2023-06-01') : '2023-06-01',
|
|
565
675
|
'anthropic-beta': beta,
|
|
566
676
|
'x-client-request-id': randomUUID(),
|
|
@@ -595,6 +705,18 @@ export async function startProxy(opts = {}) {
|
|
|
595
705
|
body: finalBody ? new Uint8Array(finalBody) : undefined,
|
|
596
706
|
signal: upstreamAbort.signal,
|
|
597
707
|
});
|
|
708
|
+
// Pool mode: capture rate-limit snapshot from the response. parseRateLimits
|
|
709
|
+
// returns status='rejected' on 429, which makes the next `select()` call
|
|
710
|
+
// route traffic away from this account until it resets.
|
|
711
|
+
if (pool && poolAccount) {
|
|
712
|
+
const snapshot = parseRateLimits(upstream.headers);
|
|
713
|
+
if (upstream.status === 429) {
|
|
714
|
+
pool.markRejected(poolAccount.alias, snapshot);
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
pool.updateRateLimits(poolAccount.alias, snapshot);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
598
720
|
// Auto-retry without context-1m if it triggers a long-context billing error.
|
|
599
721
|
// Anthropic returns this as either 400 ("long context beta is not yet available
|
|
600
722
|
// for this subscription") or 429 ("Extra usage is required for long context
|
|
@@ -622,6 +744,16 @@ export async function startProxy(opts = {}) {
|
|
|
622
744
|
// Use the retry response from here on — peeked body is now stale
|
|
623
745
|
upstream = retry;
|
|
624
746
|
peekedBody = null;
|
|
747
|
+
// Pool mode: re-capture after the context-1m retry as the snapshot may have changed.
|
|
748
|
+
if (pool && poolAccount) {
|
|
749
|
+
const retrySnapshot = parseRateLimits(upstream.headers);
|
|
750
|
+
if (upstream.status === 429) {
|
|
751
|
+
pool.markRejected(poolAccount.alias, retrySnapshot);
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
pool.updateRateLimits(poolAccount.alias, retrySnapshot);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
625
757
|
}
|
|
626
758
|
else if (upstream.status === 429) {
|
|
627
759
|
// Not a context-1m issue — return enriched 429 directly
|