@askalf/dario 6.9.3 → 6.10.1

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/ledger.js CHANGED
@@ -241,6 +241,31 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
241
241
  }
242
242
  return result;
243
243
  }
244
+ /**
245
+ * What one consumer has used so far TODAY (UTC), priced the same way as the
246
+ * headline: covered and metered rows both count, because a budget is about the
247
+ * traffic a key caused, not about who paid for it. Tokens are all four buckets.
248
+ * The key-budget check (src/keys.ts budgetVerdict) reads this per request.
249
+ */
250
+ export function consumerDayUsage(file, consumer, now = Date.now()) {
251
+ const day = dayKey(now);
252
+ const models = file.consumers?.[day]?.[consumer];
253
+ const out = { usd: 0, tokens: 0, requests: 0 };
254
+ if (!models)
255
+ return out;
256
+ const at = dayMs(day);
257
+ for (const [model, row] of Object.entries(models)) {
258
+ for (const cell of [row.covered, row.metered]) {
259
+ if (!cell)
260
+ continue;
261
+ out.usd += costOfTokens(model, at, cell);
262
+ out.tokens += cell.inputTokens + cell.outputTokens + cell.cacheReadTokens + cell.cacheCreateTokens;
263
+ out.requests += cell.requests;
264
+ }
265
+ }
266
+ out.usd = round(out.usd);
267
+ return out;
268
+ }
244
269
  function addTokens(into, cell) {
245
270
  into.inputTokens += cell.inputTokens;
246
271
  into.outputTokens += cell.outputTokens;
@@ -405,6 +430,10 @@ export class Ledger {
405
430
  summary(now = Date.now()) {
406
431
  return summarizeLedger(this.file, this.path, now);
407
432
  }
433
+ /** Today's usage for one consumer — the key-budget check's input. */
434
+ consumerToday(consumer, now = Date.now()) {
435
+ return consumerDayUsage(this.file, consumer, now);
436
+ }
408
437
  /** The raw per-day table, for /analytics/ledger. */
409
438
  snapshot() {
410
439
  return JSON.parse(JSON.stringify(this.file));
package/dist/metrics.d.ts CHANGED
@@ -19,6 +19,13 @@ export interface MetricsInput {
19
19
  /** Most recent records, newest last — the latency quantiles come from these. */
20
20
  recent: readonly RequestRecord[];
21
21
  version: string;
22
+ /** Per-key daily budgets and today's use (keys with a budget only); absent when keys or the ledger are off. */
23
+ budgets?: Record<string, {
24
+ usdPerDay: number | null;
25
+ tokensPerDay: number | null;
26
+ usedUsd: number;
27
+ usedTokens: number;
28
+ }>;
22
29
  }
23
30
  /** Nearest-rank quantile over a sorted ascending array. */
24
31
  export declare function quantile(sorted: readonly number[], q: number): number;
package/dist/metrics.js CHANGED
@@ -117,6 +117,14 @@ export function renderPrometheus(input) {
117
117
  out.push(`${fam.name}_count ${vals.length}`);
118
118
  }
119
119
  }
120
+ // ---- per-key daily budgets (dario#1318 follow-up) ----------------------
121
+ const budgets = Object.entries(input.budgets ?? {});
122
+ if (budgets.length > 0) {
123
+ metric('dario_key_budget_usd_per_day', 'Daily API-equivalent cap per named key, USD (keys with a dollar cap).', budgets.filter(([, b]) => b.usdPerDay !== null).map(([key, b]) => [{ key }, b.usdPerDay]));
124
+ metric('dario_key_budget_used_usd', 'API-equivalent spend per budgeted key today (UTC), USD.', budgets.map(([key, b]) => [{ key }, b.usedUsd]));
125
+ metric('dario_key_budget_tokens_per_day', 'Daily token cap per named key (keys with a token cap).', budgets.filter(([, b]) => b.tokensPerDay !== null).map(([key, b]) => [{ key }, b.tokensPerDay]));
126
+ metric('dario_key_budget_used_tokens', 'Tokens per budgeted key today (UTC), all buckets.', budgets.map(([key, b]) => [{ key }, b.usedTokens]));
127
+ }
120
128
  // ---- predictions -------------------------------------------------------
121
129
  const p = summary.predictions;
122
130
  if (p.estimatedExhaustionMinutes !== null) {
package/dist/pool.d.ts CHANGED
@@ -66,6 +66,27 @@ export interface RateLimitSnapshot {
66
66
  * static seed and the evidence.
67
67
  */
68
68
  boundBuckets?: Record<string, string[]>;
69
+ /**
70
+ * Set by `markRejected` when the 429 named an exhausted PER-MODEL bucket
71
+ * while the unified 5h/7d windows still had room — `['oi']` for a Pro seat
72
+ * whose included-overage credit is spent (that is what Fable is metered on)
73
+ * while Opus keeps serving on the same seat. Only the families those buckets
74
+ * bind (`bucketsBindingFamily`) are kept off the seat, until
75
+ * `parkedBucketsUntil` (epoch ms, the bucket's own reset). Carried across
76
+ * later readings by `withParkedBuckets`: an Opus response carries no `7d_oi`
77
+ * header, so without that the next Fable request would re-learn the same
78
+ * 429. Absent (or past its reset) on every other snapshot.
79
+ */
80
+ parkedBuckets?: string[];
81
+ parkedBucketsUntil?: number;
82
+ /**
83
+ * The seat's OWN windows' status — the worse of `5h-status` and `7d-status`
84
+ * on the wire (`allowed` / `allowed_warning` / `rejected`), when the response
85
+ * carried them. `status` is the request's verdict; on a bucket-scoped 429 the
86
+ * two differ (`rejected` for Fable, `allowed_warning` for the seat), and the
87
+ * operator surfaces report this one for such a seat, since it is serving.
88
+ */
89
+ seatStatus?: string;
69
90
  }
70
91
  export declare const EMPTY_SNAPSHOT: RateLimitSnapshot;
71
92
  /** Freshness of an account's utilisation reading — see `utilFreshness`. */
@@ -278,9 +299,25 @@ export declare function rateLimitWindowPassed(rl: RateLimitSnapshot, now?: numbe
278
299
  * exactly the state this is: no current observation.
279
300
  */
280
301
  export declare function reportedAccountStatus(account: PoolAccount, now?: number): string;
281
- export declare function accountIneligibility(account: PoolAccount, now?: number): AccountIneligibility | null;
302
+ export declare function accountIneligibility(account: PoolAccount, now?: number, family?: string | null): AccountIneligibility | null;
282
303
  /** Boolean form of `accountIneligibility` — the router's eligibility filter. */
283
- export declare function isAccountEligible(account: PoolAccount, now?: number): boolean;
304
+ export declare function isAccountEligible(account: PoolAccount, now?: number, family?: string | null): boolean;
305
+ /**
306
+ * A rejection whose only exhausted reading was a per-model bucket (see
307
+ * `RateLimitSnapshot.parkedBuckets`): the seat itself has room, one family
308
+ * does not.
309
+ */
310
+ export declare function isBucketScopedRejection(rl: RateLimitSnapshot): boolean;
311
+ /** The buckets still parking families on this snapshot right now. */
312
+ export declare function activeParkedBuckets(rl: RateLimitSnapshot, now?: number): string[];
313
+ /**
314
+ * Is `family` kept off this seat by a parked per-model bucket? False with no
315
+ * family: the buckets are per family, and a caller with none to name is asking
316
+ * about the seat, which those buckets do not park.
317
+ */
318
+ export declare function familyParkedOnBuckets(rl: RateLimitSnapshot, family: string | null | undefined, now?: number): boolean;
319
+ /** `next` with the parked buckets `prev` still holds carried forward (see `RateLimitSnapshot.parkedBuckets`). */
320
+ export declare function withParkedBuckets(prev: RateLimitSnapshot, next: RateLimitSnapshot, now?: number): RateLimitSnapshot;
284
321
  /**
285
322
  * A seat parked on a 429 whose stated window has not rolled yet — the one
286
323
  * state the router must never re-probe: the 429 named the reset, the clock
@@ -288,7 +325,7 @@ export declare function isAccountEligible(account: PoolAccount, now?: number): b
288
325
  * stated reset is NOT this: with nothing to expire, asking is the only way
289
326
  * back, so it stays probeable (dario#1244).
290
327
  */
291
- export declare function isParkedInLiveWindow(account: PoolAccount, now?: number): boolean;
328
+ export declare function isParkedInLiveWindow(account: PoolAccount, now?: number, family?: string | null): boolean;
292
329
  /**
293
330
  * A seat rejected by a 429 that named no exhausted window, still inside the
294
331
  * cool-down that rejection earned (see `RateLimitSnapshot.exhausted`). It is
@@ -314,7 +351,7 @@ export declare function isCoolingAfterRejection(account: PoolAccount, now?: numb
314
351
  * token, which no amount of waiting fixes and which these paths handle
315
352
  * separately.
316
353
  */
317
- export declare function isProbeable(account: PoolAccount, now?: number): boolean;
354
+ export declare function isProbeable(account: PoolAccount, now?: number, family?: string | null): boolean;
318
355
  /**
319
356
  * The operator's next step for one seat, next to `status` on both listings
320
357
  * (dario#1244 — "do I have to re-login?" should not need the docs table).
@@ -399,6 +436,10 @@ export declare function parseRateLimits(headers: Headers, family?: string | null
399
436
  * seat's window rolls. 0.99 rather than 1 absorbs float formatting.
400
437
  */
401
438
  export declare function isWindowRejection(rl: RateLimitSnapshot): boolean;
439
+ /** The 429 showed one of the seat's own windows (5h or 7d) at the threshold. */
440
+ export declare function isUnifiedWindowRejection(rl: RateLimitSnapshot): boolean;
441
+ /** The per-model buckets this reading shows at the threshold. */
442
+ export declare function exhaustedBuckets(rl: RateLimitSnapshot): string[];
402
443
  /** Cool-down for a 429 that named no exhausted window, when it stated no `retry-after`. */
403
444
  export declare const NON_WINDOW_REJECTION_COOLDOWN_MS = 60000;
404
445
  /**
@@ -415,6 +456,8 @@ export declare const NON_WINDOW_REJECTION_COOLDOWN_MS = 60000;
415
456
  * bucket is captured automatically the moment Anthropic starts emitting it —
416
457
  * this function is what lets routing USE it).
417
458
  */
459
+ /** The families `modelFamily` recognises — a per-model bucket named for one (`7d_sonnet`) binds it by name. */
460
+ export declare const KNOWN_FAMILIES: readonly string[];
418
461
  export declare function modelFamily(modelId: string | null | undefined): string | null;
419
462
  /**
420
463
  * Compute headroom for a single account given its rate-limit snapshot.
@@ -473,6 +516,8 @@ export declare function computeHeadroom(snapshot: RateLimitSnapshot, family?: st
473
516
  */
474
517
  export declare function expireElapsedWindow(snapshot: RateLimitSnapshot, now?: number): RateLimitSnapshot;
475
518
  /** Every bucket name that binds `family` for this reading — by name, by seed, or as learned. */
519
+ /** The families `buckets` bind on this snapshot — the inverse of `bucketsBindingFamily`, for reporting. */
520
+ export declare function familiesBoundToBuckets(snapshot: RateLimitSnapshot, buckets: readonly string[]): string[];
476
521
  export declare function bucketsBindingFamily(snapshot: RateLimitSnapshot, family: string): string[];
477
522
  /**
478
523
  * Default headroom floor under which an account is treated as "effectively
@@ -570,7 +615,7 @@ export declare class AccountPool {
570
615
  * pools stay on the existing unavailable handling (dario#1244, and the
571
616
  * review on dario#1254 that caught the mixed case).
572
617
  */
573
- parkedUntil(now?: number): number | null;
618
+ parkedUntil(now?: number, family?: string | null): number | null;
574
619
  /** Seats a rate limit is currently keeping out of rotation (dario#1244, #1264). */
575
620
  parkedCount(now?: number): number;
576
621
  /**
package/dist/pool.js CHANGED
@@ -102,6 +102,12 @@ export function maskEmail(email) {
102
102
  return `${(user ?? '').slice(0, 2)}***@${domain ?? ''}`;
103
103
  }
104
104
  export function describeRejection(rl, now = Date.now()) {
105
+ if (isBucketScopedRejection(rl)) {
106
+ const buckets = (rl.parkedBuckets ?? []).map((b) => `7d_${b}`).join(', ');
107
+ const families = familiesBoundToBuckets(rl, rl.parkedBuckets ?? []);
108
+ const who = families.length > 0 ? families.join('/') : 'the families it binds';
109
+ return `${describeRateLimitSnapshot(rl, now)} — ${buckets} exhausted: ${who} parked on this seat until it rolls, other families still served`;
110
+ }
105
111
  if (rl.exhausted !== false)
106
112
  return `${describeRateLimitSnapshot(rl, now)} — parked until the window rolls`;
107
113
  const { resetInMs } = rateLimitWindow(rl, now);
@@ -249,14 +255,29 @@ export function reportedAccountStatus(account, now = Date.now()) {
249
255
  return 'auth-cooldown';
250
256
  if (account.rateLimit.status === 'rejected' && rateLimitWindowPassed(account.rateLimit, now))
251
257
  return 'unknown';
258
+ // A bucket-scoped rejection is one family's verdict; the seat is serving the
259
+ // rest, and its status is its own windows' reading (or unobserved).
260
+ if (isBucketScopedRejection(account.rateLimit))
261
+ return account.rateLimit.seatStatus ?? 'unknown';
252
262
  return account.rateLimit.status;
253
263
  }
254
- export function accountIneligibility(account, now = Date.now()) {
264
+ export function accountIneligibility(account, now = Date.now(), family) {
255
265
  // A rejection outlives its own window unless it is allowed to expire:
256
266
  // nothing refreshes a parked account's snapshot, because being parked is
257
- // what stops it being sent requests.
258
- if (account.rateLimit.status === 'rejected' && !rateLimitWindowPassed(account.rateLimit, now))
267
+ // what stops it being sent requests. A rejection scoped to per-model
268
+ // buckets keeps only the families they bind off the seat; with no family
269
+ // to ask about (the seat-level surfaces) it is not a seat-wide rejection.
270
+ if (account.rateLimit.status === 'rejected' && !rateLimitWindowPassed(account.rateLimit, now)) {
271
+ if (!isBucketScopedRejection(account.rateLimit))
272
+ return 'rate-limited';
273
+ if (familyParkedOnBuckets(account.rateLimit, family, now))
274
+ return 'rate-limited';
275
+ }
276
+ else if (familyParkedOnBuckets(account.rateLimit, family, now)) {
277
+ // The seat served something since (an Opus 200 replaced the reading) and
278
+ // the parked buckets were carried forward: still off for this family.
259
279
  return 'rate-limited';
280
+ }
260
281
  if (account.expiresAt <= now + TOKEN_EXPIRY_MARGIN_MS)
261
282
  return 'token-expired';
262
283
  if (isInAuthCooldown(account, now))
@@ -264,8 +285,44 @@ export function accountIneligibility(account, now = Date.now()) {
264
285
  return null;
265
286
  }
266
287
  /** Boolean form of `accountIneligibility` — the router's eligibility filter. */
267
- export function isAccountEligible(account, now = Date.now()) {
268
- return accountIneligibility(account, now) === null;
288
+ export function isAccountEligible(account, now = Date.now(), family) {
289
+ return accountIneligibility(account, now, family) === null;
290
+ }
291
+ /**
292
+ * A rejection whose only exhausted reading was a per-model bucket (see
293
+ * `RateLimitSnapshot.parkedBuckets`): the seat itself has room, one family
294
+ * does not.
295
+ */
296
+ export function isBucketScopedRejection(rl) {
297
+ return rl.status === 'rejected' && rl.exhausted !== false && (rl.parkedBuckets?.length ?? 0) > 0;
298
+ }
299
+ /** The buckets still parking families on this snapshot right now. */
300
+ export function activeParkedBuckets(rl, now = Date.now()) {
301
+ if (!rl.parkedBuckets || rl.parkedBuckets.length === 0)
302
+ return [];
303
+ if (rl.parkedBucketsUntil === undefined || rl.parkedBucketsUntil <= now)
304
+ return [];
305
+ return rl.parkedBuckets;
306
+ }
307
+ /**
308
+ * Is `family` kept off this seat by a parked per-model bucket? False with no
309
+ * family: the buckets are per family, and a caller with none to name is asking
310
+ * about the seat, which those buckets do not park.
311
+ */
312
+ export function familyParkedOnBuckets(rl, family, now = Date.now()) {
313
+ if (!family)
314
+ return false;
315
+ const parked = activeParkedBuckets(rl, now);
316
+ if (parked.length === 0)
317
+ return false;
318
+ return bucketsBindingFamily(rl, family).some((b) => parked.includes(b));
319
+ }
320
+ /** `next` with the parked buckets `prev` still holds carried forward (see `RateLimitSnapshot.parkedBuckets`). */
321
+ export function withParkedBuckets(prev, next, now = Date.now()) {
322
+ const parked = activeParkedBuckets(prev, now);
323
+ if (parked.length === 0)
324
+ return next;
325
+ return { ...next, parkedBuckets: parked, parkedBucketsUntil: prev.parkedBucketsUntil };
269
326
  }
270
327
  /**
271
328
  * A seat parked on a 429 whose stated window has not rolled yet — the one
@@ -274,12 +331,18 @@ export function isAccountEligible(account, now = Date.now()) {
274
331
  * stated reset is NOT this: with nothing to expire, asking is the only way
275
332
  * back, so it stays probeable (dario#1244).
276
333
  */
277
- export function isParkedInLiveWindow(account, now = Date.now()) {
334
+ export function isParkedInLiveWindow(account, now = Date.now(), family) {
278
335
  const rl = account.rateLimit;
279
336
  // A non-exhausted rejection is never "parked in a live window": its reset
280
337
  // was not this seat's window, and once its cool-down passes asking is the
281
338
  // 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;
339
+ if (!(rl.status === 'rejected' && rl.exhausted !== false && rl.reset > 0 && rl.reset * 1000 > now))
340
+ return false;
341
+ // A bucket-scoped rejection parks its families, not the seat: for any
342
+ // other family, or for no family at all, the window is not this seat's.
343
+ if (isBucketScopedRejection(rl))
344
+ return familyParkedOnBuckets(rl, family, now);
345
+ return true;
283
346
  }
284
347
  /**
285
348
  * A seat rejected by a 429 that named no exhausted window, still inside the
@@ -310,9 +373,9 @@ export function isCoolingAfterRejection(account, now = Date.now()) {
310
373
  * token, which no amount of waiting fixes and which these paths handle
311
374
  * separately.
312
375
  */
313
- export function isProbeable(account, now = Date.now()) {
376
+ export function isProbeable(account, now = Date.now(), family) {
314
377
  return !isInAuthCooldown(account, now)
315
- && !isParkedInLiveWindow(account, now)
378
+ && !isParkedInLiveWindow(account, now, family)
316
379
  && !isCoolingAfterRejection(account, now);
317
380
  }
318
381
  /**
@@ -437,6 +500,12 @@ export function parseRateLimits(headers, family) {
437
500
  rejectedBuckets.push(st[1].toLowerCase());
438
501
  }
439
502
  const claim = get('representative-claim') || 'unknown';
503
+ // The seat's own windows, when the response names them (see `seatStatus`).
504
+ const windowStatuses = ['5h-status', '7d-status'].map((k) => get(k).trim().toLowerCase()).filter(Boolean);
505
+ const seatStatus = windowStatuses.length === 0 ? undefined
506
+ : windowStatuses.includes('rejected') ? 'rejected'
507
+ : windowStatuses.includes('allowed_warning') ? 'allowed_warning'
508
+ : windowStatuses[0];
440
509
  // What THIS response proved about which bucket binds the request's family
441
510
  // (see WIRE_BUCKET_BINDINGS). Only with a family to attribute to, and only
442
511
  // on the wire's own say-so: an overage-included claim means the request was
@@ -465,6 +534,7 @@ export function parseRateLimits(headers, family) {
465
534
  updatedAt: Date.now(),
466
535
  retryAfterMs: parseRetryAfterMs(headers.get('retry-after')),
467
536
  ...(boundBuckets ? { boundBuckets } : {}),
537
+ ...(seatStatus ? { seatStatus } : {}),
468
538
  };
469
539
  }
470
540
  /**
@@ -482,8 +552,15 @@ export function isWindowRejection(rl) {
482
552
  // and a real rejection carries one, but its absence must not turn a 104%
483
553
  // reading into a mere cool-down — under-parking a genuinely exhausted seat
484
554
  // 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);
555
+ return isUnifiedWindowRejection(rl) || exhaustedBuckets(rl).length > 0;
556
+ }
557
+ /** The 429 showed one of the seat's own windows (5h or 7d) at the threshold. */
558
+ export function isUnifiedWindowRejection(rl) {
559
+ return rl.util5h >= 0.99 || rl.util7d >= 0.99;
560
+ }
561
+ /** The per-model buckets this reading shows at the threshold. */
562
+ export function exhaustedBuckets(rl) {
563
+ return Object.entries(rl.perModel7d).filter(([, u]) => u >= 0.99).map(([b]) => b);
487
564
  }
488
565
  /** Cool-down for a 429 that named no exhausted window, when it stated no `retry-after`. */
489
566
  export const NON_WINDOW_REJECTION_COOLDOWN_MS = 60_000;
@@ -501,6 +578,8 @@ export const NON_WINDOW_REJECTION_COOLDOWN_MS = 60_000;
501
578
  * bucket is captured automatically the moment Anthropic starts emitting it —
502
579
  * this function is what lets routing USE it).
503
580
  */
581
+ /** The families `modelFamily` recognises — a per-model bucket named for one (`7d_sonnet`) binds it by name. */
582
+ export const KNOWN_FAMILIES = ['opus', 'sonnet', 'haiku', 'fable'];
504
583
  export function modelFamily(modelId) {
505
584
  if (!modelId)
506
585
  return null;
@@ -599,6 +678,22 @@ export function expireElapsedWindow(snapshot, now = Date.now()) {
599
678
  return snapshot;
600
679
  }
601
680
  /** Every bucket name that binds `family` for this reading — by name, by seed, or as learned. */
681
+ /** The families `buckets` bind on this snapshot — the inverse of `bucketsBindingFamily`, for reporting. */
682
+ export function familiesBoundToBuckets(snapshot, buckets) {
683
+ const out = [];
684
+ const add = (f) => { if (!out.includes(f))
685
+ out.push(f); };
686
+ for (const b of buckets) {
687
+ if (KNOWN_FAMILIES.includes(b))
688
+ add(b);
689
+ for (const f of WIRE_BUCKET_BINDINGS[b] ?? [])
690
+ add(f);
691
+ for (const [f, bound] of Object.entries(snapshot.boundBuckets ?? {}))
692
+ if (bound.includes(b))
693
+ add(f);
694
+ }
695
+ return out;
696
+ }
602
697
  export function bucketsBindingFamily(snapshot, family) {
603
698
  const out = [family];
604
699
  for (const [bucket, families] of Object.entries(WIRE_BUCKET_BINDINGS)) {
@@ -827,7 +922,7 @@ export class AccountPool {
827
922
  return null;
828
923
  const now = Date.now();
829
924
  const all = [...this.accounts.values()];
830
- const eligible = all.filter(a => isAccountEligible(a, now));
925
+ const eligible = all.filter(a => isAccountEligible(a, now, family));
831
926
  if (eligible.length > 0) {
832
927
  if (this.strategy === 'fill-first') {
833
928
  const first = pickFillFirst(eligible, family, this.headroomFloor);
@@ -853,7 +948,7 @@ export class AccountPool {
853
948
  // What is left — a rejection with no stated reset (nothing to expire, so
854
949
  // asking is the only way back) or an expiring token — is tried least-used
855
950
  // first, as before.
856
- const probeable = all.filter(a => isProbeable(a, now));
951
+ const probeable = all.filter(a => isProbeable(a, now, family));
857
952
  if (probeable.length === 0)
858
953
  return null;
859
954
  return probeable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
@@ -867,7 +962,7 @@ export class AccountPool {
867
962
  * pools stay on the existing unavailable handling (dario#1244, and the
868
963
  * review on dario#1254 that caught the mixed case).
869
964
  */
870
- parkedUntil(now = Date.now()) {
965
+ parkedUntil(now = Date.now(), family) {
871
966
  if (this.accounts.size === 0)
872
967
  return null;
873
968
  const all = [...this.accounts.values()];
@@ -878,10 +973,10 @@ export class AccountPool {
878
973
  // 429 again (dario#1264 review). An auth cool-down or an expired token
879
974
  // still does NOT count — those are not rate limits and must not be
880
975
  // reported, or cooled, as if they were.
881
- if (!all.every(a => isParkedInLiveWindow(a, now) || isCoolingAfterRejection(a, now)))
976
+ if (!all.every(a => isParkedInLiveWindow(a, now, family) || isCoolingAfterRejection(a, now)))
882
977
  return null;
883
- return Math.min(...all.map(a => isParkedInLiveWindow(a, now)
884
- ? a.rateLimit.reset * 1000
978
+ return Math.min(...all.map(a => isParkedInLiveWindow(a, now, family)
979
+ ? (isBucketScopedRejection(a.rateLimit) ? (a.rateLimit.parkedBucketsUntil ?? a.rateLimit.reset * 1000) : a.rateLimit.reset * 1000)
885
980
  : a.rateLimit.cooldownUntil ?? now));
886
981
  }
887
982
  /** Seats a rate limit is currently keeping out of rotation (dario#1244, #1264). */
@@ -912,7 +1007,7 @@ export class AccountPool {
912
1007
  if (binding) {
913
1008
  const bound = this.accounts.get(binding.alias);
914
1009
  if (bound
915
- && isAccountEligible(bound, now)
1010
+ && isAccountEligible(bound, now, family)
916
1011
  && computeHeadroom(bound.rateLimit, family) > this.headroomFloor) {
917
1012
  // Refresh the idle timer. A session that keeps taking turns must never
918
1013
  // be reaped or rebound while active — that would strand its warm prompt
@@ -990,7 +1085,7 @@ export class AccountPool {
990
1085
  return null;
991
1086
  const now = Date.now();
992
1087
  const candidates = [...this.accounts.values()].filter(a => !excluded.has(a.alias));
993
- const eligible = candidates.filter(a => isAccountEligible(a, now));
1088
+ const eligible = candidates.filter(a => isAccountEligible(a, now, family));
994
1089
  if (eligible.length > 0) {
995
1090
  // Fill-first failover keeps the fill order: the next account tried
996
1091
  // after a 429 is the next alias in line, not the max-headroom seat —
@@ -1007,7 +1102,7 @@ export class AccountPool {
1007
1102
  // parked inside a live window is not one of them — on the dario#1244
1008
1103
  // gateway every request walked all six parked seats, six guaranteed 429s
1009
1104
  // a request. Cool-downs are skipped for the same reason.
1010
- const probeable = candidates.filter(a => isProbeable(a, now));
1105
+ const probeable = candidates.filter(a => isProbeable(a, now, family));
1011
1106
  if (probeable.length > 0) {
1012
1107
  return probeable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
1013
1108
  }
@@ -1017,7 +1112,7 @@ export class AccountPool {
1017
1112
  const account = this.accounts.get(alias);
1018
1113
  if (!account)
1019
1114
  return;
1020
- account.rateLimit = withBoundBuckets(account.rateLimit, snapshot);
1115
+ account.rateLimit = withParkedBuckets(account.rateLimit, withBoundBuckets(account.rateLimit, snapshot), snapshot.updatedAt || Date.now());
1021
1116
  account.adoptedFrom = undefined;
1022
1117
  account.requestCount++;
1023
1118
  }
@@ -1042,14 +1137,30 @@ export class AccountPool {
1042
1137
  // the upstream's own `retry-after`, or a minute, and stays probeable.
1043
1138
  const exhausted = isWindowRejection(snapshot);
1044
1139
  const merged = withBoundBuckets(account.rateLimit, snapshot);
1045
- account.rateLimit = exhausted
1046
- ? { ...merged, status: 'rejected', exhausted: true }
1047
- : {
1140
+ // A 429 whose only exhausted reading is a per-model bucket, with the
1141
+ // seat's own windows under the threshold, parks that bucket's families
1142
+ // and nothing else (dario#1262 follow-up, 2026-09-21): a Pro seat at
1143
+ // `5h 3%, 7d 83%, 7d_oi 1.02 rejected` had refused Fable — metered on the
1144
+ // included-overage credit — while answering Opus 200 on the very next
1145
+ // request, and the old seat-wide park took it out of rotation for every
1146
+ // model until the 7-day reset, three days out.
1147
+ const buckets = exhausted && !isUnifiedWindowRejection(snapshot) ? exhaustedBuckets(snapshot) : [];
1148
+ account.rateLimit = buckets.length > 0
1149
+ ? {
1048
1150
  ...merged,
1049
1151
  status: 'rejected',
1050
- exhausted: false,
1051
- cooldownUntil: now + (snapshot.retryAfterMs ?? NON_WINDOW_REJECTION_COOLDOWN_MS),
1052
- };
1152
+ exhausted: true,
1153
+ parkedBuckets: buckets,
1154
+ parkedBucketsUntil: snapshot.reset > 0 ? snapshot.reset * 1000 : now + NON_WINDOW_REJECTION_COOLDOWN_MS,
1155
+ }
1156
+ : exhausted
1157
+ ? { ...merged, status: 'rejected', exhausted: true, parkedBuckets: undefined, parkedBucketsUntil: undefined }
1158
+ : {
1159
+ ...merged,
1160
+ status: 'rejected',
1161
+ exhausted: false,
1162
+ cooldownUntil: now + (snapshot.retryAfterMs ?? NON_WINDOW_REJECTION_COOLDOWN_MS),
1163
+ };
1053
1164
  account.adoptedFrom = undefined;
1054
1165
  account.rejectedCount++;
1055
1166
  account.lastRejectedAt = now;