@askalf/dario 6.0.38 → 6.0.39
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.d.ts +87 -0
- package/dist/accounts.js +162 -19
- package/dist/admin-api.d.ts +14 -1
- package/dist/admin-api.js +10 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +80 -3
- package/dist/doctor-core.d.ts +34 -0
- package/dist/doctor-core.js +91 -3
- package/dist/pool.d.ts +146 -17
- package/dist/pool.js +264 -54
- package/dist/proxy.js +86 -28
- package/docs/admin-api.md +1 -3
- package/docs/commands.md +1 -0
- package/docs/multi-account-pool.md +26 -7
- package/package.json +1 -1
package/dist/pool.js
CHANGED
|
@@ -88,6 +88,27 @@ export function describeRateLimitSnapshot(rl, now = Date.now()) {
|
|
|
88
88
|
: `resets in ${formatDurationMs(resetInMs)}`;
|
|
89
89
|
return `5h ${pct(rl.util5h)}, 7d ${pct(rl.util7d)}, claim ${rl.claim}, ${reset}`;
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The log line for a 429 as `markRejected` classified it: an exhausted window
|
|
93
|
+
* parks the seat until its reset; anything else cools briefly and says which
|
|
94
|
+
* stated reset was NOT honoured, so the operator can see the reading dario
|
|
95
|
+
* declined to act on.
|
|
96
|
+
*/
|
|
97
|
+
/** `th***@example.com` — enough to recognise an account by eye, never the whole address on a listing or a log. */
|
|
98
|
+
export function maskEmail(email) {
|
|
99
|
+
if (typeof email !== 'string' || !email.includes('@'))
|
|
100
|
+
return null;
|
|
101
|
+
const [user, domain] = email.split('@', 2);
|
|
102
|
+
return `${(user ?? '').slice(0, 2)}***@${domain ?? ''}`;
|
|
103
|
+
}
|
|
104
|
+
export function describeRejection(rl, now = Date.now()) {
|
|
105
|
+
if (rl.exhausted !== false)
|
|
106
|
+
return `${describeRateLimitSnapshot(rl, now)} — parked until the window rolls`;
|
|
107
|
+
const { resetInMs } = rateLimitWindow(rl, now);
|
|
108
|
+
const stated = resetInMs === null ? 'no reset stated' : `stated reset in ${formatDurationMs(resetInMs)} not honoured`;
|
|
109
|
+
const cool = Math.max(0, (rl.cooldownUntil ?? now) - now);
|
|
110
|
+
return `429 without an exhausted window (5h ${Math.round(rl.util5h * 100)}%, 7d ${Math.round(rl.util7d * 100)}%, claim ${rl.claim}; ${stated}) — cooling ${formatDurationMs(cool)}, seat stays probeable`;
|
|
111
|
+
}
|
|
91
112
|
function formatDurationMs(ms) {
|
|
92
113
|
const totalMins = Math.max(1, Math.round(ms / 60_000));
|
|
93
114
|
const h = Math.floor(totalMins / 60);
|
|
@@ -95,59 +116,51 @@ function formatDurationMs(ms) {
|
|
|
95
116
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
96
117
|
}
|
|
97
118
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* states no live window (no reset, a reset that has passed, or no claim).
|
|
119
|
+
* For every seat, the other aliases that are the SAME Anthropic account —
|
|
120
|
+
* one subscription under several aliases (dario#1244).
|
|
101
121
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* is
|
|
122
|
+
* Identity, not inference. The first version of this keyed seats on
|
|
123
|
+
* `claim@reset` — "two independent windows all but never share a reset
|
|
124
|
+
* second". They do: Anthropic aligns the five-hour reset to a 20-minute grid
|
|
125
|
+
* (dario#1263 — two demonstrably different accounts, both resetting at
|
|
126
|
+
* exactly :40:00), so a five-hour window has 15 possible reset seconds and a
|
|
127
|
+
* pool of 18 seats is GUARANTEED collisions. That heuristic told an operator
|
|
128
|
+
* seven independent colleagues were one subscription. `accountId` is the
|
|
129
|
+
* account uuid the OAuth grant belongs to (accounts.ts, `fetchOAuthProfile`);
|
|
130
|
+
* two seats with the same one are the same account, full stop, and two
|
|
131
|
+
* seats without one are simply not yet identified — never guessed.
|
|
108
132
|
*/
|
|
109
|
-
export function
|
|
110
|
-
|
|
111
|
-
return null;
|
|
112
|
-
if (!rl.claim || rl.claim === 'unknown')
|
|
113
|
-
return null;
|
|
114
|
-
return `${rl.claim}@${rl.reset}`;
|
|
115
|
-
}
|
|
116
|
-
/** For every seat, the other aliases whose last reading names the same live window. */
|
|
117
|
-
export function windowPeers(accounts, now) {
|
|
118
|
-
const byKey = new Map();
|
|
133
|
+
export function accountPeers(accounts) {
|
|
134
|
+
const byId = new Map();
|
|
119
135
|
for (const a of accounts) {
|
|
120
|
-
|
|
121
|
-
if (!k)
|
|
136
|
+
if (!a.accountId)
|
|
122
137
|
continue;
|
|
123
|
-
const list =
|
|
138
|
+
const list = byId.get(a.accountId);
|
|
124
139
|
if (list)
|
|
125
140
|
list.push(a.alias);
|
|
126
141
|
else
|
|
127
|
-
|
|
142
|
+
byId.set(a.accountId, [a.alias]);
|
|
128
143
|
}
|
|
129
144
|
const out = new Map();
|
|
130
145
|
for (const a of accounts) {
|
|
131
|
-
|
|
132
|
-
out.set(a.alias, k ? (byKey.get(k) ?? []).filter((alias) => alias !== a.alias) : []);
|
|
146
|
+
out.set(a.alias, a.accountId ? (byId.get(a.accountId) ?? []).filter((alias) => alias !== a.alias) : []);
|
|
133
147
|
}
|
|
134
148
|
return out;
|
|
135
149
|
}
|
|
136
150
|
/**
|
|
137
|
-
* How many
|
|
138
|
-
* each seat
|
|
151
|
+
* How many accounts the pool really has: each identified account once, and
|
|
152
|
+
* each seat not yet identified as its own (nothing says otherwise).
|
|
139
153
|
*/
|
|
140
|
-
export function
|
|
141
|
-
const
|
|
142
|
-
let
|
|
154
|
+
export function distinctAccounts(accounts) {
|
|
155
|
+
const ids = new Set();
|
|
156
|
+
let unidentified = 0;
|
|
143
157
|
for (const a of accounts) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
keys.add(k);
|
|
158
|
+
if (a.accountId)
|
|
159
|
+
ids.add(a.accountId);
|
|
147
160
|
else
|
|
148
|
-
|
|
161
|
+
unidentified++;
|
|
149
162
|
}
|
|
150
|
-
return
|
|
163
|
+
return ids.size + unidentified;
|
|
151
164
|
}
|
|
152
165
|
/**
|
|
153
166
|
* Cool-down schedule after auth failures. First failure: 60s. Each
|
|
@@ -212,6 +225,10 @@ export const TOKEN_EXPIRY_MARGIN_MS = 30_000;
|
|
|
212
225
|
* would push a genuinely throttled account back into rotation.
|
|
213
226
|
*/
|
|
214
227
|
export function rateLimitWindowPassed(rl, now = Date.now()) {
|
|
228
|
+
// A rejection that named no exhausted window is over when its cool-down is:
|
|
229
|
+
// the stated reset was never this seat's window (see RateLimitSnapshot.exhausted).
|
|
230
|
+
if (rl.exhausted === false)
|
|
231
|
+
return !(rl.cooldownUntil !== undefined && now < rl.cooldownUntil);
|
|
215
232
|
return rl.reset > 0 && rl.reset * 1000 <= now;
|
|
216
233
|
}
|
|
217
234
|
/**
|
|
@@ -259,7 +276,44 @@ export function isAccountEligible(account, now = Date.now()) {
|
|
|
259
276
|
*/
|
|
260
277
|
export function isParkedInLiveWindow(account, now = Date.now()) {
|
|
261
278
|
const rl = account.rateLimit;
|
|
262
|
-
|
|
279
|
+
// A non-exhausted rejection is never "parked in a live window": its reset
|
|
280
|
+
// was not this seat's window, and once its cool-down passes asking is the
|
|
281
|
+
// way back (the all-exhausted branch may probe it even sooner).
|
|
282
|
+
return rl.status === 'rejected' && rl.exhausted !== false && rl.reset > 0 && rl.reset * 1000 > now;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* A seat rejected by a 429 that named no exhausted window, still inside the
|
|
286
|
+
* cool-down that rejection earned (see `RateLimitSnapshot.exhausted`). It is
|
|
287
|
+
* deliberately NOT "parked in a live window" — the reset it stated was never
|
|
288
|
+
* this seat's window — but it is equally not askable yet: the upstream said
|
|
289
|
+
* retry after N, and asking sooner is the retry storm the cool-down exists to
|
|
290
|
+
* prevent (dario#1264 review).
|
|
291
|
+
*/
|
|
292
|
+
export function isCoolingAfterRejection(account, now = Date.now()) {
|
|
293
|
+
const rl = account.rateLimit;
|
|
294
|
+
return rl.status === 'rejected' && rl.exhausted === false
|
|
295
|
+
&& rl.cooldownUntil !== undefined && now < rl.cooldownUntil;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Whether the router may send this seat a request RIGHT NOW, for reasons that
|
|
299
|
+
* expire on their own: an auth cool-down, a live rate-limit window, or the
|
|
300
|
+
* cool-down a non-window 429 earned.
|
|
301
|
+
*
|
|
302
|
+
* ONE predicate, filtered on by both fallback paths (the all-exhausted branch
|
|
303
|
+
* in `select()` and the mid-flight `selectExcluding()`). They carried the
|
|
304
|
+
* condition inline and independently, so `isCoolingAfterRejection` was added to
|
|
305
|
+
* neither — a seat that had just answered `retry-after: 17` could be retried
|
|
306
|
+
* inside the same client request. Caught in review on #1264; the shape of the
|
|
307
|
+
* bug is why this is a named predicate rather than two copies of a filter.
|
|
308
|
+
*
|
|
309
|
+
* Note this is NOT `isAccountEligible`: eligibility also refuses an expired
|
|
310
|
+
* token, which no amount of waiting fixes and which these paths handle
|
|
311
|
+
* separately.
|
|
312
|
+
*/
|
|
313
|
+
export function isProbeable(account, now = Date.now()) {
|
|
314
|
+
return !isInAuthCooldown(account, now)
|
|
315
|
+
&& !isParkedInLiveWindow(account, now)
|
|
316
|
+
&& !isCoolingAfterRejection(account, now);
|
|
263
317
|
}
|
|
264
318
|
/**
|
|
265
319
|
* The operator's next step for one seat, next to `status` on both listings
|
|
@@ -299,10 +353,70 @@ export function resolvePoolStrategy(explicit, env = process.env) {
|
|
|
299
353
|
* normalized to lowercase to match `modelFamily()` output.
|
|
300
354
|
*/
|
|
301
355
|
const PER_MODEL_7D_HEADER = /^anthropic-ratelimit-unified-7d_([a-z0-9-]+)-utilization$/i;
|
|
356
|
+
/** `…-7d_<bucket>-status`: `rejected` names the bucket that refused this request. */
|
|
357
|
+
const PER_MODEL_7D_STATUS_HEADER = /^anthropic-ratelimit-unified-7d_([a-z0-9-]+)-status$/i;
|
|
358
|
+
/**
|
|
359
|
+
* Which wire buckets bind which model family, when the wire does not say it
|
|
360
|
+
* by name. `7d_sonnet` names its family; `7d_oi` does not — it is the plan's
|
|
361
|
+
* INCLUDED-OVERAGE credit, and it is what Fable's weekly allowance is metered
|
|
362
|
+
* on (dario#1262):
|
|
363
|
+
*
|
|
364
|
+
* - 2026-07-05, live Max account: Fable drew `representative-claim:
|
|
365
|
+
* seven_day_overage_included` at 7d 82% with `7d_oi` at 99%, served at $0;
|
|
366
|
+
* at `7d_oi` ≥ 1.0 Fable answered a hard 429 (`7d_oi-status: rejected`,
|
|
367
|
+
* `7d_oi-surpassed-threshold: 1.0`) while Opus kept serving.
|
|
368
|
+
* - 2026-09-08 (#1262): a Fable response at `7d 0.63, 7d_oi 0.98` — headroom
|
|
369
|
+
* read 0.37 against a seat two points from refusal.
|
|
370
|
+
*
|
|
371
|
+
* So for Fable, `oi` IS the binding weekly bucket, under a name that is not
|
|
372
|
+
* "fable". This seed makes that true from the first response. Bindings for
|
|
373
|
+
* other families are LEARNED per account from the wire (see
|
|
374
|
+
* `parseRateLimits`): a response whose claim is `*_overage_included`, or a 429
|
|
375
|
+
* whose `7d_<bucket>-status` is `rejected`, proves that bucket binds the family
|
|
376
|
+
* that request was for. Nothing here maps a family to `oi` by assumption:
|
|
377
|
+
* a seat drawing on included overage for Opus is bound by `oi` for Opus
|
|
378
|
+
* exactly when its responses say so.
|
|
379
|
+
*/
|
|
380
|
+
export const WIRE_BUCKET_BINDINGS = {
|
|
381
|
+
oi: ['fable'],
|
|
382
|
+
};
|
|
383
|
+
/** Parse `retry-after`: delta-seconds or an HTTP date; null when absent or unreadable. */
|
|
384
|
+
export function parseRetryAfterMs(value, now = Date.now()) {
|
|
385
|
+
if (!value)
|
|
386
|
+
return null;
|
|
387
|
+
const secs = Number(value);
|
|
388
|
+
if (Number.isFinite(secs) && secs >= 0)
|
|
389
|
+
return Math.round(secs * 1000);
|
|
390
|
+
const at = Date.parse(value);
|
|
391
|
+
return Number.isFinite(at) ? Math.max(0, at - now) : null;
|
|
392
|
+
}
|
|
393
|
+
/** Union of two readings' learned bindings, per family, order-preserving. */
|
|
394
|
+
export function mergeBoundBuckets(prev, next) {
|
|
395
|
+
if (!prev)
|
|
396
|
+
return next;
|
|
397
|
+
if (!next)
|
|
398
|
+
return prev;
|
|
399
|
+
const out = {};
|
|
400
|
+
for (const src of [prev, next]) {
|
|
401
|
+
for (const [family, buckets] of Object.entries(src)) {
|
|
402
|
+
const list = out[family] ?? (out[family] = []);
|
|
403
|
+
for (const b of buckets)
|
|
404
|
+
if (!list.includes(b))
|
|
405
|
+
list.push(b);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
/** `next` with everything the account had already learned carried forward. */
|
|
411
|
+
export function withBoundBuckets(prev, next) {
|
|
412
|
+
const merged = mergeBoundBuckets(prev.boundBuckets, next.boundBuckets);
|
|
413
|
+
return merged ? { ...next, boundBuckets: merged } : next;
|
|
414
|
+
}
|
|
302
415
|
/** Parse an Anthropic response's rate-limit headers into a snapshot. */
|
|
303
|
-
export function parseRateLimits(headers) {
|
|
416
|
+
export function parseRateLimits(headers, family) {
|
|
304
417
|
const get = (key) => headers.get(`anthropic-ratelimit-unified-${key}`) ?? '';
|
|
305
418
|
const perModel7d = {};
|
|
419
|
+
const rejectedBuckets = [];
|
|
306
420
|
// Iterate the full header set — `headers.get` only retrieves known
|
|
307
421
|
// keys, but Anthropic can add new `7d_<family>-utilization` shapes
|
|
308
422
|
// unannounced. Scanning the iterator means the parser is automatically
|
|
@@ -316,7 +430,28 @@ export function parseRateLimits(headers) {
|
|
|
316
430
|
const m = k.match(PER_MODEL_7D_HEADER);
|
|
317
431
|
if (m && m[1]) {
|
|
318
432
|
perModel7d[m[1].toLowerCase()] = parseFloat(v) || 0;
|
|
433
|
+
continue;
|
|
319
434
|
}
|
|
435
|
+
const st = k.match(PER_MODEL_7D_STATUS_HEADER);
|
|
436
|
+
if (st && st[1] && v.trim().toLowerCase() === 'rejected')
|
|
437
|
+
rejectedBuckets.push(st[1].toLowerCase());
|
|
438
|
+
}
|
|
439
|
+
const claim = get('representative-claim') || 'unknown';
|
|
440
|
+
// What THIS response proved about which bucket binds the request's family
|
|
441
|
+
// (see WIRE_BUCKET_BINDINGS). Only with a family to attribute to, and only
|
|
442
|
+
// on the wire's own say-so: an overage-included claim means the request was
|
|
443
|
+
// metered on `oi`; a bucket that reports itself rejected is the one that
|
|
444
|
+
// refused this family.
|
|
445
|
+
let boundBuckets;
|
|
446
|
+
if (family) {
|
|
447
|
+
const bound = [];
|
|
448
|
+
if (/_overage_included$/.test(claim) && perModel7d['oi'] !== undefined)
|
|
449
|
+
bound.push('oi');
|
|
450
|
+
for (const b of rejectedBuckets)
|
|
451
|
+
if (perModel7d[b] !== undefined && !bound.includes(b))
|
|
452
|
+
bound.push(b);
|
|
453
|
+
if (bound.length > 0)
|
|
454
|
+
boundBuckets = { [family]: bound };
|
|
320
455
|
}
|
|
321
456
|
return {
|
|
322
457
|
status: get('status') || 'unknown',
|
|
@@ -328,8 +463,30 @@ export function parseRateLimits(headers) {
|
|
|
328
463
|
reset: parseInt(get('reset')) || 0,
|
|
329
464
|
fallbackPct: parseFloat(get('fallback-percentage')) || 0,
|
|
330
465
|
updatedAt: Date.now(),
|
|
466
|
+
retryAfterMs: parseRetryAfterMs(headers.get('retry-after')),
|
|
467
|
+
...(boundBuckets ? { boundBuckets } : {}),
|
|
331
468
|
};
|
|
332
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* Does this 429 say a rate-limit WINDOW is over? True only when the reading
|
|
472
|
+
* itself shows one: a utilization at or past the 1.0 threshold on any window
|
|
473
|
+
* or bucket (the unified headers are a ratio against
|
|
474
|
+
* `surpassed-threshold: 1.0`, so `1.02` is 102%; every live rejection observed
|
|
475
|
+
* has read 1.00–1.06). A 429 with a claim but 30% used, or with no claim and
|
|
476
|
+
* 0% used, is a refusal of some other kind — concurrency, an account-level
|
|
477
|
+
* lock, a monthly credit — and its stated `reset` is not the moment this
|
|
478
|
+
* seat's window rolls. 0.99 rather than 1 absorbs float formatting.
|
|
479
|
+
*/
|
|
480
|
+
export function isWindowRejection(rl) {
|
|
481
|
+
// Utilization alone decides. The representative claim names WHICH window,
|
|
482
|
+
// and a real rejection carries one, but its absence must not turn a 104%
|
|
483
|
+
// reading into a mere cool-down — under-parking a genuinely exhausted seat
|
|
484
|
+
// re-probes it every minute, which is the loop #1254 removed.
|
|
485
|
+
const utils = [rl.util5h, rl.util7d, ...Object.values(rl.perModel7d)];
|
|
486
|
+
return utils.some((u) => u >= 0.99);
|
|
487
|
+
}
|
|
488
|
+
/** Cool-down for a 429 that named no exhausted window, when it stated no `retry-after`. */
|
|
489
|
+
export const NON_WINDOW_REJECTION_COOLDOWN_MS = 60_000;
|
|
333
490
|
/**
|
|
334
491
|
* Extract the model family (`opus` / `sonnet` / `haiku` / `fable`) from a
|
|
335
492
|
* request's model id. Used to look up the per-model 7d bucket in
|
|
@@ -368,16 +525,34 @@ export function modelFamily(modelId) {
|
|
|
368
525
|
* isn't represented in the snapshot (e.g. account hasn't seen a Sonnet
|
|
369
526
|
* request yet so `7d_sonnet` is unknown), headroom is computed from the
|
|
370
527
|
* unified buckets only — best-effort, populated on the next response.
|
|
528
|
+
*
|
|
529
|
+
* A bucket counts for a family when it names the family (`7d_sonnet`), when
|
|
530
|
+
* `WIRE_BUCKET_BINDINGS` says it binds the family (`7d_oi` → fable, dario#1262),
|
|
531
|
+
* or when this account's responses have shown it does (`boundBuckets`).
|
|
371
532
|
*/
|
|
372
533
|
export function computeHeadroom(snapshot, family) {
|
|
373
534
|
const utils = [snapshot.util5h, snapshot.util7d];
|
|
374
535
|
if (family) {
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
536
|
+
for (const bucket of bucketsBindingFamily(snapshot, family)) {
|
|
537
|
+
const util = snapshot.perModel7d[bucket];
|
|
538
|
+
if (util !== undefined)
|
|
539
|
+
utils.push(util);
|
|
540
|
+
}
|
|
378
541
|
}
|
|
379
542
|
return 1 - Math.max(...utils);
|
|
380
543
|
}
|
|
544
|
+
/** Every bucket name that binds `family` for this reading — by name, by seed, or as learned. */
|
|
545
|
+
export function bucketsBindingFamily(snapshot, family) {
|
|
546
|
+
const out = [family];
|
|
547
|
+
for (const [bucket, families] of Object.entries(WIRE_BUCKET_BINDINGS)) {
|
|
548
|
+
if (families.includes(family) && !out.includes(bucket))
|
|
549
|
+
out.push(bucket);
|
|
550
|
+
}
|
|
551
|
+
for (const bucket of snapshot.boundBuckets?.[family] ?? [])
|
|
552
|
+
if (!out.includes(bucket))
|
|
553
|
+
out.push(bucket);
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
381
556
|
const STICKY_IDLE_TTL_MS = 6 * 60 * 60 * 1000; // reap a binding 6h after its LAST use, not its creation
|
|
382
557
|
const STICKY_MAX_ENTRIES = 2_000; // lazy cleanup cap
|
|
383
558
|
const STICKY_CLEANUP_INTERVAL_MS = 30_000; // amortize the O(n) TTL/orphan sweep
|
|
@@ -454,12 +629,22 @@ export class AccountPool {
|
|
|
454
629
|
expiresAt: opts.expiresAt,
|
|
455
630
|
grantedAt: opts.grantedAt ?? keep?.grantedAt,
|
|
456
631
|
organizationId: opts.organizationId ?? keep?.organizationId,
|
|
632
|
+
accountId: opts.accountId ?? keep?.accountId,
|
|
633
|
+
accountEmail: opts.accountEmail ?? keep?.accountEmail,
|
|
634
|
+
rateLimitTier: opts.rateLimitTier ?? keep?.rateLimitTier,
|
|
635
|
+
seatTier: opts.seatTier ?? keep?.seatTier,
|
|
457
636
|
adoptedFrom: keep?.adoptedFrom,
|
|
458
|
-
identity:
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
637
|
+
// The record's client identity is authoritative: `dario accounts identity
|
|
638
|
+
// --fresh` rewrites it on disk, and the running seat must present the new
|
|
639
|
+
// one on its next request rather than the old one until a restart. The
|
|
640
|
+
// session id is the seat's own and is kept.
|
|
641
|
+
identity: keep?.identity && keep.identity.deviceId === opts.deviceId && keep.identity.accountUuid === opts.accountUuid
|
|
642
|
+
? keep.identity
|
|
643
|
+
: {
|
|
644
|
+
deviceId: opts.deviceId,
|
|
645
|
+
accountUuid: opts.accountUuid,
|
|
646
|
+
sessionId: keep?.identity?.sessionId ?? randomUUID(),
|
|
647
|
+
},
|
|
463
648
|
rateLimit: keep?.rateLimit ?? { ...EMPTY_SNAPSHOT },
|
|
464
649
|
requestCount: keep?.requestCount ?? 0,
|
|
465
650
|
rejectedCount: keep?.rejectedCount ?? 0,
|
|
@@ -554,7 +739,7 @@ export class AccountPool {
|
|
|
554
739
|
// What is left — a rejection with no stated reset (nothing to expire, so
|
|
555
740
|
// asking is the only way back) or an expiring token — is tried least-used
|
|
556
741
|
// first, as before.
|
|
557
|
-
const probeable = all.filter(a =>
|
|
742
|
+
const probeable = all.filter(a => isProbeable(a, now));
|
|
558
743
|
if (probeable.length === 0)
|
|
559
744
|
return null;
|
|
560
745
|
return probeable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
|
|
@@ -572,13 +757,22 @@ export class AccountPool {
|
|
|
572
757
|
if (this.accounts.size === 0)
|
|
573
758
|
return null;
|
|
574
759
|
const all = [...this.accounts.values()];
|
|
575
|
-
|
|
760
|
+
// A seat cooling after a non-window 429 counts here: it is over a rate
|
|
761
|
+
// limit of some kind and it comes back on its own, which is exactly what
|
|
762
|
+
// this answer means. Without it a pool of cooling seats reported "not
|
|
763
|
+
// parked", fell past the local-429, and spent a probe that could only
|
|
764
|
+
// 429 again (dario#1264 review). An auth cool-down or an expired token
|
|
765
|
+
// still does NOT count — those are not rate limits and must not be
|
|
766
|
+
// reported, or cooled, as if they were.
|
|
767
|
+
if (!all.every(a => isParkedInLiveWindow(a, now) || isCoolingAfterRejection(a, now)))
|
|
576
768
|
return null;
|
|
577
|
-
return Math.min(...all.map(a => a
|
|
769
|
+
return Math.min(...all.map(a => isParkedInLiveWindow(a, now)
|
|
770
|
+
? a.rateLimit.reset * 1000
|
|
771
|
+
: a.rateLimit.cooldownUntil ?? now));
|
|
578
772
|
}
|
|
579
|
-
/** Seats currently
|
|
773
|
+
/** Seats a rate limit is currently keeping out of rotation (dario#1244, #1264). */
|
|
580
774
|
parkedCount(now = Date.now()) {
|
|
581
|
-
return [...this.accounts.values()].filter(a => isParkedInLiveWindow(a, now)).length;
|
|
775
|
+
return [...this.accounts.values()].filter(a => isParkedInLiveWindow(a, now) || isCoolingAfterRejection(a, now)).length;
|
|
582
776
|
}
|
|
583
777
|
/**
|
|
584
778
|
* Select with session stickiness. If `stickyKey` is already bound to a
|
|
@@ -699,7 +893,7 @@ export class AccountPool {
|
|
|
699
893
|
// parked inside a live window is not one of them — on the dario#1244
|
|
700
894
|
// gateway every request walked all six parked seats, six guaranteed 429s
|
|
701
895
|
// a request. Cool-downs are skipped for the same reason.
|
|
702
|
-
const probeable = candidates.filter(a =>
|
|
896
|
+
const probeable = candidates.filter(a => isProbeable(a, now));
|
|
703
897
|
if (probeable.length > 0) {
|
|
704
898
|
return probeable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
|
|
705
899
|
}
|
|
@@ -709,7 +903,7 @@ export class AccountPool {
|
|
|
709
903
|
const account = this.accounts.get(alias);
|
|
710
904
|
if (!account)
|
|
711
905
|
return;
|
|
712
|
-
account.rateLimit = snapshot;
|
|
906
|
+
account.rateLimit = withBoundBuckets(account.rateLimit, snapshot);
|
|
713
907
|
account.adoptedFrom = undefined;
|
|
714
908
|
account.requestCount++;
|
|
715
909
|
}
|
|
@@ -726,7 +920,22 @@ export class AccountPool {
|
|
|
726
920
|
return false;
|
|
727
921
|
const now = snapshot.updatedAt || Date.now();
|
|
728
922
|
const wasParked = account.rateLimit.status === 'rejected' && !rateLimitWindowPassed(account.rateLimit, now);
|
|
729
|
-
|
|
923
|
+
// The status code says "no"; the headers say WHY. Only a 429 that names an
|
|
924
|
+
// exhausted window is parked until that window's reset. Any other 429 —
|
|
925
|
+
// no claim, or a claim with utilization nowhere near the threshold — is
|
|
926
|
+
// not this seat's window rolling, whatever `reset` it states (the fleet box
|
|
927
|
+
// parked a seat for 546 hours on `5h 0%, 7d 0%, claim unknown`). It cools for
|
|
928
|
+
// the upstream's own `retry-after`, or a minute, and stays probeable.
|
|
929
|
+
const exhausted = isWindowRejection(snapshot);
|
|
930
|
+
const merged = withBoundBuckets(account.rateLimit, snapshot);
|
|
931
|
+
account.rateLimit = exhausted
|
|
932
|
+
? { ...merged, status: 'rejected', exhausted: true }
|
|
933
|
+
: {
|
|
934
|
+
...merged,
|
|
935
|
+
status: 'rejected',
|
|
936
|
+
exhausted: false,
|
|
937
|
+
cooldownUntil: now + (snapshot.retryAfterMs ?? NON_WINDOW_REJECTION_COOLDOWN_MS),
|
|
938
|
+
};
|
|
730
939
|
account.adoptedFrom = undefined;
|
|
731
940
|
account.rejectedCount++;
|
|
732
941
|
account.lastRejectedAt = now;
|
|
@@ -754,7 +963,8 @@ export class AccountPool {
|
|
|
754
963
|
const account = this.accounts.get(alias);
|
|
755
964
|
if (!account)
|
|
756
965
|
return false;
|
|
757
|
-
account.rateLimit
|
|
966
|
+
const merged = withBoundBuckets(account.rateLimit, snapshot);
|
|
967
|
+
account.rateLimit = rejected ? { ...merged, status: 'rejected' } : { ...merged };
|
|
758
968
|
account.adoptedFrom = from;
|
|
759
969
|
return true;
|
|
760
970
|
}
|