@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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Token analytics — per-request billing tracking, utilization trends,
3
+ * window exhaustion predictions, cost estimation.
4
+ *
5
+ * In-memory rolling window; exposed via the /analytics endpoint when
6
+ * pool mode is active.
7
+ */
8
+ // Anthropic pricing (per 1M tokens, USD). Not authoritative — used for
9
+ // rough burn-rate display in the /analytics summary.
10
+ const PRICING = {
11
+ 'claude-opus-4-6': { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },
12
+ 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },
13
+ 'claude-haiku-4-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheCreate: 1 },
14
+ };
15
+ function estimateCost(record) {
16
+ const p = PRICING[record.model] ?? PRICING['claude-sonnet-4-6'];
17
+ return ((record.inputTokens * p.input) +
18
+ (record.outputTokens * p.output) +
19
+ (record.cacheReadTokens * p.cacheRead) +
20
+ (record.cacheCreateTokens * p.cacheCreate)) / 1_000_000;
21
+ }
22
+ export class Analytics {
23
+ records = [];
24
+ maxRecords;
25
+ constructor(maxRecords = 10_000) {
26
+ this.maxRecords = maxRecords;
27
+ }
28
+ record(r) {
29
+ this.records.push(r);
30
+ if (this.records.length > this.maxRecords) {
31
+ this.records = this.records.slice(-this.maxRecords);
32
+ }
33
+ }
34
+ /** Parse usage from a non-streaming Anthropic response body. */
35
+ static parseUsage(body) {
36
+ const u = body.usage;
37
+ const content = body.content;
38
+ const thinkingChars = content
39
+ ?.filter(b => b.type === 'thinking')
40
+ .reduce((s, b) => s + (b.thinking?.length ?? 0), 0) ?? 0;
41
+ const thinkingTokens = Math.round(thinkingChars / 4);
42
+ return {
43
+ inputTokens: u?.input_tokens ?? 0,
44
+ outputTokens: u?.output_tokens ?? 0,
45
+ cacheReadTokens: u?.cache_read_input_tokens ?? 0,
46
+ cacheCreateTokens: u?.cache_creation_input_tokens ?? 0,
47
+ thinkingTokens,
48
+ model: body.model ?? 'unknown',
49
+ };
50
+ }
51
+ summary(windowMinutes = 60) {
52
+ const cutoff = Date.now() - windowMinutes * 60_000;
53
+ const recent = this.records.filter(r => r.timestamp >= cutoff);
54
+ const allTime = this.records;
55
+ return {
56
+ window: {
57
+ minutes: windowMinutes,
58
+ requests: recent.length,
59
+ ...this.computeStats(recent),
60
+ },
61
+ allTime: {
62
+ requests: allTime.length,
63
+ ...this.computeStats(allTime),
64
+ },
65
+ perAccount: this.perAccountStats(recent),
66
+ perModel: this.perModelStats(recent),
67
+ utilization: this.utilizationTrend(recent),
68
+ predictions: this.predict(recent),
69
+ };
70
+ }
71
+ computeStats(records) {
72
+ if (records.length === 0) {
73
+ return {
74
+ totalInputTokens: 0, totalOutputTokens: 0, totalThinkingTokens: 0,
75
+ estimatedCost: 0, avgLatencyMs: 0, errorRate: 0,
76
+ claimBreakdown: {},
77
+ };
78
+ }
79
+ const totalInput = records.reduce((s, r) => s + r.inputTokens, 0);
80
+ const totalOutput = records.reduce((s, r) => s + r.outputTokens, 0);
81
+ const totalThinking = records.reduce((s, r) => s + r.thinkingTokens, 0);
82
+ const cost = records.reduce((s, r) => s + estimateCost(r), 0);
83
+ const avgLatency = records.reduce((s, r) => s + r.latencyMs, 0) / records.length;
84
+ const errors = records.filter(r => r.status >= 400).length;
85
+ const claims = {};
86
+ for (const r of records) {
87
+ claims[r.claim] = (claims[r.claim] ?? 0) + 1;
88
+ }
89
+ return {
90
+ totalInputTokens: totalInput,
91
+ totalOutputTokens: totalOutput,
92
+ totalThinkingTokens: totalThinking,
93
+ estimatedCost: Math.round(cost * 10000) / 10000,
94
+ avgLatencyMs: Math.round(avgLatency),
95
+ errorRate: Math.round((errors / records.length) * 10000) / 10000,
96
+ claimBreakdown: claims,
97
+ };
98
+ }
99
+ perAccountStats(records) {
100
+ const grouped = {};
101
+ for (const r of records) {
102
+ (grouped[r.account] ??= []).push(r);
103
+ }
104
+ const result = {};
105
+ for (const [account, recs] of Object.entries(grouped)) {
106
+ const last = recs[recs.length - 1];
107
+ result[account] = {
108
+ requests: recs.length,
109
+ inputTokens: recs.reduce((s, r) => s + r.inputTokens, 0),
110
+ outputTokens: recs.reduce((s, r) => s + r.outputTokens, 0),
111
+ estimatedCost: Math.round(recs.reduce((s, r) => s + estimateCost(r), 0) * 10000) / 10000,
112
+ currentUtil5h: last.util5h,
113
+ currentUtil7d: last.util7d,
114
+ lastClaim: last.claim,
115
+ };
116
+ }
117
+ return result;
118
+ }
119
+ perModelStats(records) {
120
+ const grouped = {};
121
+ for (const r of records) {
122
+ (grouped[r.model] ??= []).push(r);
123
+ }
124
+ const result = {};
125
+ for (const [model, recs] of Object.entries(grouped)) {
126
+ result[model] = {
127
+ requests: recs.length,
128
+ avgInputTokens: Math.round(recs.reduce((s, r) => s + r.inputTokens, 0) / recs.length),
129
+ avgOutputTokens: Math.round(recs.reduce((s, r) => s + r.outputTokens, 0) / recs.length),
130
+ avgThinkingTokens: Math.round(recs.reduce((s, r) => s + r.thinkingTokens, 0) / recs.length),
131
+ estimatedCost: Math.round(recs.reduce((s, r) => s + estimateCost(r), 0) * 10000) / 10000,
132
+ };
133
+ }
134
+ return result;
135
+ }
136
+ utilizationTrend(records) {
137
+ if (records.length === 0)
138
+ return [];
139
+ const bucketMs = 5 * 60_000;
140
+ const buckets = new Map();
141
+ for (const r of records) {
142
+ const key = Math.floor(r.timestamp / bucketMs) * bucketMs;
143
+ const existing = buckets.get(key);
144
+ if (existing) {
145
+ existing.push(r);
146
+ }
147
+ else {
148
+ buckets.set(key, [r]);
149
+ }
150
+ }
151
+ return [...buckets.entries()]
152
+ .sort(([a], [b]) => a - b)
153
+ .map(([ts, recs]) => ({
154
+ timestamp: ts,
155
+ avgUtil5h: Math.round(recs.reduce((s, r) => s + r.util5h, 0) / recs.length * 100) / 100,
156
+ avgUtil7d: Math.round(recs.reduce((s, r) => s + r.util7d, 0) / recs.length * 100) / 100,
157
+ requests: recs.length,
158
+ }));
159
+ }
160
+ predict(records) {
161
+ if (records.length < 3) {
162
+ return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, costBurnRate: 0 };
163
+ }
164
+ const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp);
165
+ const first = sorted[0];
166
+ const last = sorted[sorted.length - 1];
167
+ const durationMin = (last.timestamp - first.timestamp) / 60_000;
168
+ if (durationMin < 1) {
169
+ return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, costBurnRate: 0 };
170
+ }
171
+ const totalTokens = sorted.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
172
+ const totalCost = sorted.reduce((s, r) => s + estimateCost(r), 0);
173
+ const tokenBurnRate = totalTokens / durationMin;
174
+ const costBurnRate = (totalCost / durationMin) * 60;
175
+ const currentUtil = last.util5h;
176
+ if (currentUtil >= 0.95) {
177
+ return {
178
+ estimatedExhaustionMinutes: 0,
179
+ tokenBurnRate: Math.round(tokenBurnRate),
180
+ costBurnRate: Math.round(costBurnRate * 100) / 100,
181
+ };
182
+ }
183
+ const utilGrowthRate = (last.util5h - first.util5h) / durationMin;
184
+ if (utilGrowthRate <= 0) {
185
+ return {
186
+ estimatedExhaustionMinutes: null,
187
+ tokenBurnRate: Math.round(tokenBurnRate),
188
+ costBurnRate: Math.round(costBurnRate * 100) / 100,
189
+ };
190
+ }
191
+ const minutesToExhaustion = (1.0 - currentUtil) / utilGrowthRate;
192
+ return {
193
+ estimatedExhaustionMinutes: Math.round(minutesToExhaustion),
194
+ tokenBurnRate: Math.round(tokenBurnRate),
195
+ costBurnRate: Math.round(costBurnRate * 100) / 100,
196
+ };
197
+ }
198
+ }
package/dist/cli.js CHANGED
@@ -37,6 +37,7 @@ import { join } from 'node:path';
37
37
  import { homedir } from 'node:os';
38
38
  import { startAutoOAuthFlow, getStatus, refreshTokens, loadCredentials } from './oauth.js';
39
39
  import { startProxy, sanitizeError } from './proxy.js';
40
+ import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, removeAccount } from './accounts.js';
40
41
  const args = process.argv.slice(2);
41
42
  const command = args[0] ?? 'proxy';
42
43
  async function login() {
@@ -142,6 +143,114 @@ async function proxy() {
142
143
  const model = modelArg ? modelArg.split('=')[1] : undefined;
143
144
  await startProxy({ port, host, verbose, model, passthrough, preserveTools });
144
145
  }
146
+ async function accounts() {
147
+ const sub = args[1];
148
+ if (!sub || sub === 'list') {
149
+ const aliases = await listAccountAliases();
150
+ console.log('');
151
+ console.log(' dario — Accounts');
152
+ console.log(' ────────────────');
153
+ console.log('');
154
+ if (aliases.length === 0) {
155
+ console.log(' No multi-account pool configured.');
156
+ console.log('');
157
+ console.log(' Pool mode activates automatically when ~/.dario/accounts/');
158
+ console.log(' has 2+ entries. Add the first with:');
159
+ console.log(' dario accounts add <alias>');
160
+ console.log('');
161
+ console.log(' Single-account dario (the default) keeps working as-is');
162
+ console.log(' with ~/.dario/credentials.json — you do not need to');
163
+ console.log(' migrate unless you want pool routing across accounts.');
164
+ console.log('');
165
+ return;
166
+ }
167
+ const loaded = await loadAllAccounts();
168
+ const now = Date.now();
169
+ console.log(` ${aliases.length} account${aliases.length === 1 ? '' : 's'} configured`);
170
+ if (aliases.length === 1) {
171
+ console.log(' (Pool mode needs 2+ accounts — single-account mode until another is added.)');
172
+ }
173
+ console.log('');
174
+ for (const a of loaded) {
175
+ const msLeft = Math.max(0, a.expiresAt - now);
176
+ const hours = Math.floor(msLeft / 3600000);
177
+ const mins = Math.floor((msLeft % 3600000) / 60000);
178
+ const expiry = msLeft > 0 ? `${hours}h ${mins}m` : 'expired';
179
+ console.log(` ${a.alias.padEnd(20)} token expires in ${expiry}`);
180
+ }
181
+ console.log('');
182
+ return;
183
+ }
184
+ if (sub === 'add') {
185
+ const alias = args[2];
186
+ if (!alias) {
187
+ console.error('');
188
+ console.error(' Usage: dario accounts add <alias>');
189
+ console.error('');
190
+ console.error(' <alias> is any label you want for the account (e.g. "work", "personal").');
191
+ console.error('');
192
+ process.exit(1);
193
+ }
194
+ if (!/^[a-zA-Z0-9._-]+$/.test(alias)) {
195
+ console.error('[dario] Invalid alias. Use letters, numbers, dot, underscore, dash only.');
196
+ process.exit(1);
197
+ }
198
+ const existing = await listAccountAliases();
199
+ if (existing.includes(alias)) {
200
+ console.error(`[dario] Account "${alias}" already exists. Remove it first with \`dario accounts remove ${alias}\`.`);
201
+ process.exit(1);
202
+ }
203
+ console.log('');
204
+ console.log(` Adding account "${alias}" to the pool...`);
205
+ console.log('');
206
+ try {
207
+ const creds = await addAccountViaOAuth(alias);
208
+ const minutes = Math.round((creds.expiresAt - Date.now()) / 60000);
209
+ console.log('');
210
+ console.log(` Account "${alias}" added.`);
211
+ console.log(` Token expires in ${minutes} minutes (auto-refreshes in the background).`);
212
+ const total = (await listAccountAliases()).length;
213
+ if (total >= 2) {
214
+ console.log('');
215
+ console.log(' Pool mode is now active. Restart `dario proxy` to pick up the new account.');
216
+ }
217
+ else {
218
+ console.log('');
219
+ console.log(' Add at least one more account to activate pool routing:');
220
+ console.log(' dario accounts add <another-alias>');
221
+ }
222
+ console.log('');
223
+ }
224
+ catch (err) {
225
+ console.error('');
226
+ console.error(` Failed to add account: ${sanitizeError(err)}`);
227
+ console.error('');
228
+ process.exit(1);
229
+ }
230
+ return;
231
+ }
232
+ if (sub === 'remove' || sub === 'rm') {
233
+ const alias = args[2];
234
+ if (!alias) {
235
+ console.error('');
236
+ console.error(' Usage: dario accounts remove <alias>');
237
+ console.error('');
238
+ process.exit(1);
239
+ }
240
+ const ok = await removeAccount(alias);
241
+ if (ok) {
242
+ console.log(`[dario] Account "${alias}" removed.`);
243
+ }
244
+ else {
245
+ console.error(`[dario] No account "${alias}" found.`);
246
+ process.exit(1);
247
+ }
248
+ return;
249
+ }
250
+ console.error(`[dario] Unknown accounts subcommand: ${sub}`);
251
+ console.error('Usage: dario accounts [list|add <alias>|remove <alias>]');
252
+ process.exit(1);
253
+ }
145
254
  async function help() {
146
255
  console.log(`
147
256
  dario — Use your Claude subscription as an API.
@@ -152,6 +261,9 @@ async function help() {
152
261
  dario status Check authentication status
153
262
  dario refresh Force token refresh
154
263
  dario logout Remove saved credentials
264
+ dario accounts list List accounts in the multi-account pool
265
+ dario accounts add NAME Add a new account to the pool (runs OAuth flow)
266
+ dario accounts remove N Remove an account from the pool
155
267
 
156
268
  Proxy options:
157
269
  --model=MODEL Force a model for all requests
@@ -206,6 +318,7 @@ const commands = {
206
318
  proxy,
207
319
  refresh,
208
320
  logout,
321
+ accounts,
209
322
  help,
210
323
  version,
211
324
  '--help': help,
package/dist/index.d.ts CHANGED
@@ -7,3 +7,9 @@
7
7
  export { startAutoOAuthFlow, refreshTokens, getAccessToken, getStatus, loadCredentials } from './oauth.js';
8
8
  export type { OAuthTokens, CredentialsFile } from './oauth.js';
9
9
  export { startProxy, sanitizeError } from './proxy.js';
10
+ export { AccountPool, parseRateLimits } from './pool.js';
11
+ export type { PoolAccount, PoolStatus, RateLimitSnapshot, AccountIdentity } from './pool.js';
12
+ export { listAccountAliases, loadAccount, loadAllAccounts, saveAccount, removeAccount, refreshAccountToken, addAccountViaOAuth, getAccountsDir, } from './accounts.js';
13
+ export type { AccountCredentials } from './accounts.js';
14
+ export { Analytics } from './analytics.js';
15
+ export type { RequestRecord, AnalyticsSummary } from './analytics.js';
package/dist/index.js CHANGED
@@ -6,3 +6,9 @@
6
6
  */
7
7
  export { startAutoOAuthFlow, refreshTokens, getAccessToken, getStatus, loadCredentials } from './oauth.js';
8
8
  export { startProxy, sanitizeError } from './proxy.js';
9
+ // Multi-account pool API (pool activates automatically when ~/.dario/accounts/
10
+ // contains 2+ accounts; see README for the progression from single-account
11
+ // mode to pool mode).
12
+ export { AccountPool, parseRateLimits } from './pool.js';
13
+ export { listAccountAliases, loadAccount, loadAllAccounts, saveAccount, removeAccount, refreshAccountToken, addAccountViaOAuth, getAccountsDir, } from './accounts.js';
14
+ export { Analytics } from './analytics.js';
package/dist/pool.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ export interface AccountIdentity {
2
+ deviceId: string;
3
+ accountUuid: string;
4
+ sessionId: string;
5
+ }
6
+ export interface RateLimitSnapshot {
7
+ status: string;
8
+ util5h: number;
9
+ util7d: number;
10
+ overageUtil: number;
11
+ claim: string;
12
+ reset: number;
13
+ fallbackPct: number;
14
+ updatedAt: number;
15
+ }
16
+ export declare const EMPTY_SNAPSHOT: RateLimitSnapshot;
17
+ export interface PoolAccount {
18
+ alias: string;
19
+ accessToken: string;
20
+ refreshToken: string;
21
+ expiresAt: number;
22
+ identity: AccountIdentity;
23
+ rateLimit: RateLimitSnapshot;
24
+ requestCount: number;
25
+ }
26
+ export interface PoolStatus {
27
+ accounts: number;
28
+ healthy: number;
29
+ exhausted: number;
30
+ totalHeadroom: number;
31
+ bestAccount: string;
32
+ queued: number;
33
+ }
34
+ /** Parse an Anthropic response's rate-limit headers into a snapshot. */
35
+ export declare function parseRateLimits(headers: Headers): RateLimitSnapshot;
36
+ export declare class AccountPool {
37
+ private accounts;
38
+ private queue;
39
+ private queueMaxSize;
40
+ private queueTimeoutMs;
41
+ private drainTimer;
42
+ add(alias: string, opts: {
43
+ accessToken: string;
44
+ refreshToken: string;
45
+ expiresAt: number;
46
+ deviceId: string;
47
+ accountUuid: string;
48
+ }): void;
49
+ remove(alias: string): boolean;
50
+ get size(): number;
51
+ /** Select the best account for the next request. */
52
+ select(): PoolAccount | null;
53
+ /** Select the next-best account, excluding the given alias. */
54
+ selectExcluding(excludeAlias: string): PoolAccount | null;
55
+ updateRateLimits(alias: string, snapshot: RateLimitSnapshot): void;
56
+ markRejected(alias: string, snapshot: RateLimitSnapshot): void;
57
+ updateTokens(alias: string, accessToken: string, refreshToken: string, expiresAt: number): void;
58
+ get(alias: string): PoolAccount | undefined;
59
+ all(): PoolAccount[];
60
+ status(): PoolStatus;
61
+ /**
62
+ * Wait for an available account. If all accounts are exhausted, queues
63
+ * the request and resolves when an account becomes available via
64
+ * updateRateLimits reducing utilization below threshold.
65
+ */
66
+ waitForAccount(): Promise<PoolAccount>;
67
+ private drainQueue;
68
+ }
package/dist/pool.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Account pool — rate limit tracking, headroom routing, failover.
3
+ *
4
+ * Activated automatically when `~/.dario/accounts/` contains 2+ accounts.
5
+ * Single-account dario (`~/.dario/credentials.json`) keeps the same code
6
+ * path it has always had; the pool only runs when there are multiple
7
+ * accounts to distribute against.
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ export const EMPTY_SNAPSHOT = {
11
+ status: 'unknown',
12
+ util5h: 0,
13
+ util7d: 0,
14
+ overageUtil: 0,
15
+ claim: 'unknown',
16
+ reset: 0,
17
+ fallbackPct: 0,
18
+ updatedAt: 0,
19
+ };
20
+ /** Parse an Anthropic response's rate-limit headers into a snapshot. */
21
+ export function parseRateLimits(headers) {
22
+ const get = (key) => headers.get(`anthropic-ratelimit-unified-${key}`) ?? '';
23
+ return {
24
+ status: get('status') || 'unknown',
25
+ util5h: parseFloat(get('5h-utilization')) || 0,
26
+ util7d: parseFloat(get('7d-utilization')) || 0,
27
+ overageUtil: parseFloat(get('overage-utilization')) || 0,
28
+ claim: get('representative-claim') || 'unknown',
29
+ reset: parseInt(get('reset')) || 0,
30
+ fallbackPct: parseFloat(get('fallback-percentage')) || 0,
31
+ updatedAt: Date.now(),
32
+ };
33
+ }
34
+ export class AccountPool {
35
+ accounts = new Map();
36
+ queue = [];
37
+ queueMaxSize = 50;
38
+ queueTimeoutMs = 60_000;
39
+ drainTimer = null;
40
+ add(alias, opts) {
41
+ const existing = this.accounts.get(alias);
42
+ this.accounts.set(alias, {
43
+ alias,
44
+ accessToken: opts.accessToken,
45
+ refreshToken: opts.refreshToken,
46
+ expiresAt: opts.expiresAt,
47
+ identity: existing?.identity ?? {
48
+ deviceId: opts.deviceId,
49
+ accountUuid: opts.accountUuid,
50
+ sessionId: randomUUID(),
51
+ },
52
+ rateLimit: existing?.rateLimit ?? { ...EMPTY_SNAPSHOT },
53
+ requestCount: existing?.requestCount ?? 0,
54
+ });
55
+ }
56
+ remove(alias) {
57
+ return this.accounts.delete(alias);
58
+ }
59
+ get size() {
60
+ return this.accounts.size;
61
+ }
62
+ /** Select the best account for the next request. */
63
+ select() {
64
+ if (this.accounts.size === 0)
65
+ return null;
66
+ const now = Date.now();
67
+ const all = [...this.accounts.values()];
68
+ const eligible = all.filter(a => a.rateLimit.status !== 'rejected' &&
69
+ a.expiresAt > now + 30_000);
70
+ if (eligible.length > 0) {
71
+ return eligible.reduce((best, curr) => {
72
+ const bestHeadroom = 1 - Math.max(best.rateLimit.util5h, best.rateLimit.util7d);
73
+ const currHeadroom = 1 - Math.max(curr.rateLimit.util5h, curr.rateLimit.util7d);
74
+ return currHeadroom > bestHeadroom ? curr : best;
75
+ });
76
+ }
77
+ // All accounts exhausted — return the one with the earliest reset
78
+ const withReset = all.filter(a => a.rateLimit.reset > 0);
79
+ if (withReset.length > 0) {
80
+ return withReset.reduce((a, b) => a.rateLimit.reset < b.rateLimit.reset ? a : b);
81
+ }
82
+ // No rate-limit data at all — least-used first
83
+ return all.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
84
+ }
85
+ /** Select the next-best account, excluding the given alias. */
86
+ selectExcluding(excludeAlias) {
87
+ if (this.accounts.size <= 1)
88
+ return null;
89
+ const now = Date.now();
90
+ const candidates = [...this.accounts.values()].filter(a => a.alias !== excludeAlias);
91
+ const eligible = candidates.filter(a => a.rateLimit.status !== 'rejected' &&
92
+ a.expiresAt > now + 30_000);
93
+ if (eligible.length > 0) {
94
+ return eligible.reduce((best, curr) => {
95
+ const bestHeadroom = 1 - Math.max(best.rateLimit.util5h, best.rateLimit.util7d);
96
+ const currHeadroom = 1 - Math.max(curr.rateLimit.util5h, curr.rateLimit.util7d);
97
+ return currHeadroom > bestHeadroom ? curr : best;
98
+ });
99
+ }
100
+ if (candidates.length > 0) {
101
+ return candidates.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
102
+ }
103
+ return null;
104
+ }
105
+ updateRateLimits(alias, snapshot) {
106
+ const account = this.accounts.get(alias);
107
+ if (!account)
108
+ return;
109
+ account.rateLimit = snapshot;
110
+ account.requestCount++;
111
+ }
112
+ markRejected(alias, snapshot) {
113
+ const account = this.accounts.get(alias);
114
+ if (!account)
115
+ return;
116
+ account.rateLimit = { ...snapshot, status: 'rejected' };
117
+ }
118
+ updateTokens(alias, accessToken, refreshToken, expiresAt) {
119
+ const account = this.accounts.get(alias);
120
+ if (!account)
121
+ return;
122
+ account.accessToken = accessToken;
123
+ account.refreshToken = refreshToken;
124
+ account.expiresAt = expiresAt;
125
+ }
126
+ get(alias) {
127
+ return this.accounts.get(alias);
128
+ }
129
+ all() {
130
+ return [...this.accounts.values()];
131
+ }
132
+ status() {
133
+ const all = this.all();
134
+ const now = Date.now();
135
+ const healthy = all.filter(a => a.rateLimit.status !== 'rejected' &&
136
+ a.expiresAt > now + 30_000);
137
+ const headrooms = all.map(a => 1 - Math.max(a.rateLimit.util5h, a.rateLimit.util7d));
138
+ const avgHeadroom = headrooms.length > 0 ? headrooms.reduce((a, b) => a + b, 0) / headrooms.length : 0;
139
+ const best = this.select();
140
+ return {
141
+ accounts: all.length,
142
+ healthy: healthy.length,
143
+ exhausted: all.length - healthy.length,
144
+ totalHeadroom: Math.round(avgHeadroom * 100),
145
+ bestAccount: best?.alias ?? 'none',
146
+ queued: this.queue.length,
147
+ };
148
+ }
149
+ /**
150
+ * Wait for an available account. If all accounts are exhausted, queues
151
+ * the request and resolves when an account becomes available via
152
+ * updateRateLimits reducing utilization below threshold.
153
+ */
154
+ async waitForAccount() {
155
+ const immediate = this.select();
156
+ if (immediate) {
157
+ const headroom = 1 - Math.max(immediate.rateLimit.util5h, immediate.rateLimit.util7d);
158
+ if (headroom > 0.02)
159
+ return immediate;
160
+ }
161
+ if (this.queue.length >= this.queueMaxSize) {
162
+ throw new Error('Queue full — all accounts exhausted');
163
+ }
164
+ if (!this.drainTimer) {
165
+ this.drainTimer = setInterval(() => this.drainQueue(), 5_000);
166
+ this.drainTimer.unref();
167
+ }
168
+ return new Promise((resolve, reject) => {
169
+ const entry = { resolve, reject, enqueuedAt: Date.now() };
170
+ this.queue.push(entry);
171
+ setTimeout(() => {
172
+ const idx = this.queue.indexOf(entry);
173
+ if (idx >= 0) {
174
+ this.queue.splice(idx, 1);
175
+ reject(new Error('Queue timeout — no accounts available within 60s'));
176
+ }
177
+ }, this.queueTimeoutMs);
178
+ });
179
+ }
180
+ drainQueue() {
181
+ if (this.queue.length === 0) {
182
+ if (this.drainTimer) {
183
+ clearInterval(this.drainTimer);
184
+ this.drainTimer = null;
185
+ }
186
+ return;
187
+ }
188
+ const now = Date.now();
189
+ this.queue = this.queue.filter(entry => {
190
+ if (now - entry.enqueuedAt > this.queueTimeoutMs) {
191
+ entry.reject(new Error('Queue timeout — no accounts available within 60s'));
192
+ return false;
193
+ }
194
+ return true;
195
+ });
196
+ while (this.queue.length > 0) {
197
+ const account = this.select();
198
+ if (!account)
199
+ break;
200
+ const headroom = 1 - Math.max(account.rateLimit.util5h, account.rateLimit.util7d);
201
+ if (headroom <= 0.02)
202
+ break;
203
+ const entry = this.queue.shift();
204
+ if (entry)
205
+ entry.resolve(account);
206
+ }
207
+ if (this.queue.length === 0 && this.drainTimer) {
208
+ clearInterval(this.drainTimer);
209
+ this.drainTimer = null;
210
+ }
211
+ }
212
+ }