@askalf/dario 6.10.0 → 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.
@@ -122,6 +122,13 @@ export interface AdminAccountLive {
122
122
  rejectedCount: number;
123
123
  /** Epoch ms of the most recent 429 on this account, or `null` if never. */
124
124
  lastRejectedAt: number | null;
125
+ /**
126
+ * Per-model buckets keeping their families off this seat while the seat
127
+ * itself still serves (`['oi']`: Fable parked on a Pro seat whose included
128
+ * overage is spent, Opus unaffected). Empty when none. Optional: a peer's
129
+ * snapshot from before the field behaves as before.
130
+ */
131
+ parkedBuckets?: string[];
125
132
  /** Organization observed on this seat's responses, or `null` if none yet (dario#1244). */
126
133
  organizationId: string | null;
127
134
  /**
package/dist/admin-api.js CHANGED
@@ -569,6 +569,7 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
569
569
  request_count: l.requestCount,
570
570
  rejected_count: l.rejectedCount ?? 0,
571
571
  last_rejected_at: l.lastRejectedAt ?? null,
572
+ parked_buckets: l.parkedBuckets ?? [],
572
573
  consecutive_auth_failures: l.consecutiveAuthFailures,
573
574
  } : {}),
574
575
  };
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;
package/dist/proxy.js CHANGED
@@ -13,7 +13,7 @@ import { CC_TOOL_DEFINITIONS_UNADVERTISABLE, CC_TEMPLATE_PROMPT_BYTES, resolveMa
13
13
  import { stampCch, hasCchSeed } from './cch.js';
14
14
  import { foldTiming, timingHeaders, timingLogFields } from './timing.js';
15
15
  import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
16
- import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, resolvePoolHeadroomFloor, DEFAULT_POOL_HEADROOM_FLOOR, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail, isAccountEligible } from './pool.js';
16
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, activeParkedBuckets, reconcilePoolAccounts, resolvePoolStrategy, resolvePoolHeadroomFloor, DEFAULT_POOL_HEADROOM_FLOOR, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail, isAccountEligible } from './pool.js';
17
17
  import { backfillIdentity } from './accounts.js';
18
18
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
19
19
  import { Analytics, billingBucketFromClaim, costOfTokens, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
@@ -2550,6 +2550,7 @@ export async function startProxy(opts = {}) {
2550
2550
  requestCount: a.requestCount,
2551
2551
  rejectedCount: a.rejectedCount,
2552
2552
  lastRejectedAt: a.lastRejectedAt ?? null,
2553
+ parkedBuckets: activeParkedBuckets(a.rateLimit, snapNow),
2553
2554
  organizationId: a.organizationId ?? null,
2554
2555
  sharesWindowWith: peers.get(a.alias) ?? [],
2555
2556
  sameAccountAs: peers.get(a.alias) ?? [],
@@ -2685,6 +2686,10 @@ export async function startProxy(opts = {}) {
2685
2686
  // parked seat no longer reads as one that was never called.
2686
2687
  rejectedCount: a.rejectedCount,
2687
2688
  lastRejectedAt: a.lastRejectedAt ?? null,
2689
+ // Per-model buckets keeping their families off this seat while the
2690
+ // seat itself still serves (`7d_oi` → fable on a Pro seat whose
2691
+ // included overage is spent). Empty when nothing is parked that way.
2692
+ parkedBuckets: activeParkedBuckets(a.rateLimit, now),
2688
2693
  // Which organization the token belongs to, who the token IS (OAuth
2689
2694
  // account uuid, masked email), and which other seats are the same
2690
2695
  // account — one subscription under several aliases (dario#1244,
@@ -3096,6 +3101,11 @@ export async function startProxy(opts = {}) {
3096
3101
  // before that point they remain at their initial values, which is
3097
3102
  // also exactly what we want to log on early-failure paths.
3098
3103
  let requestModel = '';
3104
+ // The Claude family the pool is asked to serve, known once the body's
3105
+ // model is read (before `selectPoolAccount()` runs): a seat parked on a
3106
+ // per-model bucket is off for that family only, so selection, the key's
3107
+ // preferred seat and the all-parked answer all ask with it.
3108
+ let requestFamily = null;
3099
3109
  let detectedClientForLog;
3100
3110
  let preserveToolsEffective = Boolean(opts.preserveTools);
3101
3111
  // Per-request: did isGenuineCCClient recognise the caller as real Claude
@@ -3285,15 +3295,15 @@ export async function startProxy(opts = {}) {
3285
3295
  // eligible right now, else the pool picks as usual. Failover
3286
3296
  // mid-request is unchanged either way — a preference, not a pin.
3287
3297
  const preferredSeat = requestAuth.key?.seat ? (pool.get(requestAuth.key.seat) ?? null) : null;
3288
- keySeatTaken = preferredSeat !== null && isAccountEligible(preferredSeat, Date.now());
3289
- poolAccount = keySeatTaken ? preferredSeat : pool.select();
3298
+ keySeatTaken = preferredSeat !== null && isAccountEligible(preferredSeat, Date.now(), requestFamily);
3299
+ poolAccount = keySeatTaken ? preferredSeat : pool.select(requestFamily);
3290
3300
  if (poolAccount)
3291
3301
  poolParkedAnnounced = false;
3292
3302
  // Every seat parked inside a live window (dario#1244): cool the
3293
3303
  // provider to the earliest reset so a fallback chain sees the Claude
3294
3304
  // half as what it is, say so once, and — unless a fallback is armed —
3295
3305
  // answer the client here instead of spending a probe that can only 429.
3296
- const parkedUntil = poolAccount ? null : pool.parkedUntil();
3306
+ const parkedUntil = poolAccount ? null : pool.parkedUntil(Date.now(), requestFamily);
3297
3307
  if (parkedUntil !== null) {
3298
3308
  providerCooldowns.note('claude', parkedUntil - Date.now());
3299
3309
  if (!poolParkedAnnounced) {
@@ -3771,6 +3781,7 @@ export async function startProxy(opts = {}) {
3771
3781
  // Reassignable: the codex effort-suffix strip below rewrites it, and
3772
3782
  // every routing decision after that point must see the stripped name.
3773
3783
  let rawModel = (peek.model || '').toString();
3784
+ requestFamily = modelFamily(rawModel);
3774
3785
  // Credentials are re-read per request (not cached at startup) because
3775
3786
  // a refresh rotates them on disk; getFreshCodexAccount refreshes when
3776
3787
  // inside the expiry buffer, collapsing concurrent refreshes per alias.
@@ -4217,7 +4228,7 @@ export async function startProxy(opts = {}) {
4217
4228
  if (!upstreamApiKey && !poolAccount) {
4218
4229
  // A fallback was armed but nothing could serve, and the pool itself is
4219
4230
  // parked: the exact reset beats a cool-down estimate (dario#1244).
4220
- const parkedNow = pool.parkedUntil();
4231
+ const parkedNow = pool.parkedUntil(Date.now(), requestFamily);
4221
4232
  if (parkedNow !== null) {
4222
4233
  writePoolParked(parkedNow);
4223
4234
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.10.0",
3
+ "version": "6.10.1",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {