@askalf/dario 6.0.33 → 6.0.35

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/pool.d.ts CHANGED
@@ -93,6 +93,26 @@ export declare function rateLimitWindow(rl: RateLimitSnapshot, now: number): Rat
93
93
  * `5h 104%, 7d 25%, claim five_hour, resets in 37m`.
94
94
  */
95
95
  export declare function describeRateLimitSnapshot(rl: RateLimitSnapshot, now?: number): string;
96
+ /**
97
+ * The identity of the rate-limit window a reading was measured against:
98
+ * its representative claim plus its reset second, or null when the reading
99
+ * states no live window (no reset, a reset that has passed, or no claim).
100
+ *
101
+ * Two seats that report the same key are one subscription under two aliases
102
+ * (dario#1244, "a few have the same issue"): two independent windows all but
103
+ * never share a reset second, and two readings of one window always do. The
104
+ * organization id is deliberately NOT part of the key — several seats can
105
+ * share an organization and still have their own windows — the window itself
106
+ * is the fact that matters for headroom.
107
+ */
108
+ export declare function windowKey(rl: RateLimitSnapshot, now: number): string | null;
109
+ /** For every seat, the other aliases whose last reading names the same live window. */
110
+ export declare function windowPeers(accounts: readonly PoolAccount[], now: number): Map<string, string[]>;
111
+ /**
112
+ * How many windows the pool really has: each measured live window once, and
113
+ * each seat without a live reading as its own (nothing says otherwise yet).
114
+ */
115
+ export declare function distinctWindows(accounts: readonly PoolAccount[], now: number): number;
96
116
  export interface PoolAccount {
97
117
  alias: string;
98
118
  accessToken: string;
@@ -111,6 +131,22 @@ export interface PoolAccount {
111
131
  rejectedCount: number;
112
132
  /** Epoch ms of the most recent 429 on this account; undefined if never. */
113
133
  lastRejectedAt?: number;
134
+ /**
135
+ * The Anthropic organization behind this seat's token, from the
136
+ * `anthropic-organization-id` response header: learned on the first
137
+ * response the seat serves, written to its record with the next token
138
+ * refresh (dario#1244 — a reading that surprises you is usually a token on
139
+ * an organization other than the one whose usage page you are looking at).
140
+ * Undefined until seen.
141
+ */
142
+ organizationId?: string;
143
+ /**
144
+ * Set when the current reading came from a peer instance (pool-sync.ts):
145
+ * that instance's id. Cleared by the next reading this instance takes
146
+ * itself. A seat parked on a peer's 429 shows `rejected` with
147
+ * `rejectedCount` unchanged — the 429 was the peer's — and this says so.
148
+ */
149
+ adoptedFrom?: string;
114
150
  /** Epoch ms of the OAuth grant (refresh-grant.ts); undefined when unknown. */
115
151
  grantedAt?: number;
116
152
  /**
@@ -194,6 +230,22 @@ export declare function reportedAccountStatus(account: PoolAccount, now?: number
194
230
  export declare function accountIneligibility(account: PoolAccount, now?: number): AccountIneligibility | null;
195
231
  /** Boolean form of `accountIneligibility` — the router's eligibility filter. */
196
232
  export declare function isAccountEligible(account: PoolAccount, now?: number): boolean;
233
+ /**
234
+ * A seat parked on a 429 whose stated window has not rolled yet — the one
235
+ * state the router must never re-probe: the 429 named the reset, the clock
236
+ * has not reached it, and a probe can only 429 again. A rejection with no
237
+ * stated reset is NOT this: with nothing to expire, asking is the only way
238
+ * back, so it stays probeable (dario#1244).
239
+ */
240
+ export declare function isParkedInLiveWindow(account: PoolAccount, now?: number): boolean;
241
+ /**
242
+ * The operator's next step for one seat, next to `status` on both listings
243
+ * (dario#1244 — "do I have to re-login?" should not need the docs table).
244
+ * `wait`: the seat comes back on its own (a live rate-limit window, or a
245
+ * single auth blip cooling down). `regrant`: an auth-failure streak, which is
246
+ * a dead refresh token. `none`: nothing to do.
247
+ */
248
+ export declare function accountAction(account: PoolAccount, now?: number): 'none' | 'wait' | 'regrant';
197
249
  export interface PoolStatus {
198
250
  accounts: number;
199
251
  healthy: number;
@@ -274,6 +326,7 @@ export declare class AccountPool {
274
326
  deviceId: string;
275
327
  accountUuid: string;
276
328
  grantedAt?: number;
329
+ organizationId?: string;
277
330
  }): void;
278
331
  remove(alias: string): boolean;
279
332
  get size(): number;
@@ -304,6 +357,18 @@ export declare class AccountPool {
304
357
  * unified-buckets-only headroom — same behavior as before this PR.
305
358
  */
306
359
  select(family?: string | null): PoolAccount | null;
360
+ /**
361
+ * When EVERY seat is parked inside a live rate-limit window: the epoch ms
362
+ * the earliest window rolls, i.e. the moment the pool can serve again
363
+ * without a probe. Null otherwise — including a pool mixing parked seats
364
+ * with an auth-cooling or token-expired one, which is not "all seats over
365
+ * their windows" and must not be reported (or cooled) as if it were; those
366
+ * pools stay on the existing unavailable handling (dario#1244, and the
367
+ * review on dario#1254 that caught the mixed case).
368
+ */
369
+ parkedUntil(now?: number): number | null;
370
+ /** Seats currently parked inside a live window (dario#1244). */
371
+ parkedCount(now?: number): number;
307
372
  /**
308
373
  * Select with session stickiness. If `stickyKey` is already bound to a
309
374
  * healthy account (not rejected, token not near expiry, headroom > 2%),
@@ -350,6 +415,19 @@ export declare class AccountPool {
350
415
  * the same seat many times, and only the transition is worth a log line.
351
416
  */
352
417
  markRejected(alias: string, snapshot: RateLimitSnapshot): boolean;
418
+ /**
419
+ * Record the organization a response said this seat belongs to. Returns
420
+ * true when it is news — the first observation, or a change (an alias
421
+ * re-granted on another organization) — so the caller persists it once.
422
+ */
423
+ noteOrganization(alias: string, organizationId: string): boolean;
424
+ /**
425
+ * Take a peer instance's reading of `alias` (pool-sync.ts): its snapshot
426
+ * replaces ours, `rejected` parks the seat on it. Counters are left alone
427
+ * — a request the peer served or a 429 it took are the peer's facts — and
428
+ * `adoptedFrom` records whose reading this is. False for an unknown alias.
429
+ */
430
+ adoptSnapshot(alias: string, snapshot: RateLimitSnapshot, rejected: boolean, from: string): boolean;
353
431
  updateTokens(alias: string, accessToken: string, refreshToken: string, expiresAt: number): void;
354
432
  get(alias: string): PoolAccount | undefined;
355
433
  all(): PoolAccount[];
@@ -372,6 +450,7 @@ export interface ReconcilableAccount {
372
450
  deviceId: string;
373
451
  accountUuid: string;
374
452
  grantedAt?: number;
453
+ organizationId?: string;
375
454
  }
376
455
  /**
377
456
  * Reconcile a live pool against the current on-disk account set: add or refresh
package/dist/pool.js CHANGED
@@ -94,6 +94,61 @@ function formatDurationMs(ms) {
94
94
  const m = totalMins % 60;
95
95
  return h > 0 ? `${h}h ${m}m` : `${m}m`;
96
96
  }
97
+ /**
98
+ * The identity of the rate-limit window a reading was measured against:
99
+ * its representative claim plus its reset second, or null when the reading
100
+ * states no live window (no reset, a reset that has passed, or no claim).
101
+ *
102
+ * Two seats that report the same key are one subscription under two aliases
103
+ * (dario#1244, "a few have the same issue"): two independent windows all but
104
+ * never share a reset second, and two readings of one window always do. The
105
+ * organization id is deliberately NOT part of the key — several seats can
106
+ * share an organization and still have their own windows — the window itself
107
+ * is the fact that matters for headroom.
108
+ */
109
+ export function windowKey(rl, now) {
110
+ if (!(rl.reset > 0) || rl.reset * 1000 <= now)
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();
119
+ for (const a of accounts) {
120
+ const k = windowKey(a.rateLimit, now);
121
+ if (!k)
122
+ continue;
123
+ const list = byKey.get(k);
124
+ if (list)
125
+ list.push(a.alias);
126
+ else
127
+ byKey.set(k, [a.alias]);
128
+ }
129
+ const out = new Map();
130
+ for (const a of accounts) {
131
+ const k = windowKey(a.rateLimit, now);
132
+ out.set(a.alias, k ? (byKey.get(k) ?? []).filter((alias) => alias !== a.alias) : []);
133
+ }
134
+ return out;
135
+ }
136
+ /**
137
+ * How many windows the pool really has: each measured live window once, and
138
+ * each seat without a live reading as its own (nothing says otherwise yet).
139
+ */
140
+ export function distinctWindows(accounts, now) {
141
+ const keys = new Set();
142
+ let unmeasured = 0;
143
+ for (const a of accounts) {
144
+ const k = windowKey(a.rateLimit, now);
145
+ if (k)
146
+ keys.add(k);
147
+ else
148
+ unmeasured++;
149
+ }
150
+ return keys.size + unmeasured;
151
+ }
97
152
  /**
98
153
  * Cool-down schedule after auth failures. First failure: 60s. Each
99
154
  * consecutive failure doubles the window up to 30 minutes. Cleared
@@ -195,6 +250,31 @@ export function accountIneligibility(account, now = Date.now()) {
195
250
  export function isAccountEligible(account, now = Date.now()) {
196
251
  return accountIneligibility(account, now) === null;
197
252
  }
253
+ /**
254
+ * A seat parked on a 429 whose stated window has not rolled yet — the one
255
+ * state the router must never re-probe: the 429 named the reset, the clock
256
+ * has not reached it, and a probe can only 429 again. A rejection with no
257
+ * stated reset is NOT this: with nothing to expire, asking is the only way
258
+ * back, so it stays probeable (dario#1244).
259
+ */
260
+ export function isParkedInLiveWindow(account, now = Date.now()) {
261
+ const rl = account.rateLimit;
262
+ return rl.status === 'rejected' && rl.reset > 0 && rl.reset * 1000 > now;
263
+ }
264
+ /**
265
+ * The operator's next step for one seat, next to `status` on both listings
266
+ * (dario#1244 — "do I have to re-login?" should not need the docs table).
267
+ * `wait`: the seat comes back on its own (a live rate-limit window, or a
268
+ * single auth blip cooling down). `regrant`: an auth-failure streak, which is
269
+ * a dead refresh token. `none`: nothing to do.
270
+ */
271
+ export function accountAction(account, now = Date.now()) {
272
+ if (isInAuthCooldown(account, now))
273
+ return account.consecutiveAuthFailures >= 2 ? 'regrant' : 'wait';
274
+ if (isParkedInLiveWindow(account, now))
275
+ return 'wait';
276
+ return 'none';
277
+ }
198
278
  /**
199
279
  * Resolve the pool strategy from an explicit value (CLI flag / config file,
200
280
  * already precedence-merged by the caller) with `DARIO_POOL_STRATEGY` as
@@ -373,6 +453,8 @@ export class AccountPool {
373
453
  refreshToken: opts.refreshToken,
374
454
  expiresAt: opts.expiresAt,
375
455
  grantedAt: opts.grantedAt ?? keep?.grantedAt,
456
+ organizationId: opts.organizationId ?? keep?.organizationId,
457
+ adoptedFrom: keep?.adoptedFrom,
376
458
  identity: keep?.identity ?? {
377
459
  deviceId: opts.deviceId,
378
460
  accountUuid: opts.accountUuid,
@@ -458,20 +540,45 @@ export class AccountPool {
458
540
  }
459
541
  return pickMaxHeadroom(eligible, family);
460
542
  }
461
- // All accounts exhausted — return the one with the earliest reset.
462
- // Auth-cooldown'd accounts are excluded from this fallback too: we
463
- // know upstream rejected their tokens, so picking them on rate-limit
464
- // grounds wouldn't help. Better to return null and let the caller
465
- // surface "no account available" than to hand back a dead account.
466
- const withReset = all.filter(a => a.rateLimit.reset > 0 && !isInAuthCooldown(a, now));
467
- if (withReset.length > 0) {
468
- return withReset.reduce((a, b) => a.rateLimit.reset < b.rateLimit.reset ? a : b);
469
- }
470
- // No rate-limit data at all — least-used first, still skipping cool-downs.
471
- const usable = all.filter(a => !isInAuthCooldown(a, now));
472
- if (usable.length === 0)
543
+ // No seat is eligible. A seat parked inside a live window is not
544
+ // re-probed: its 429 named the reset, the clock has not reached it, and a
545
+ // probe there is one upstream round trip that can only 429 again — on the
546
+ // dario#1244 gateway that was 500 probes of one seat inside a single
547
+ // window, `rejectedCount` climbing by one each time and the operator
548
+ // reading it as a seat that needed a re-login. The caller reads
549
+ // `parkedUntil()` and answers the client itself; the seat returns on its
550
+ // own when the window rolls (`rateLimitWindowPassed` makes it eligible
551
+ // again). Auth-cooldown seats are skipped for the same reason: upstream
552
+ // already rejected their tokens.
553
+ //
554
+ // What is left — a rejection with no stated reset (nothing to expire, so
555
+ // asking is the only way back) or an expiring token — is tried least-used
556
+ // first, as before.
557
+ const probeable = all.filter(a => !isInAuthCooldown(a, now) && !isParkedInLiveWindow(a, now));
558
+ if (probeable.length === 0)
473
559
  return null;
474
- return usable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
560
+ return probeable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
561
+ }
562
+ /**
563
+ * When EVERY seat is parked inside a live rate-limit window: the epoch ms
564
+ * the earliest window rolls, i.e. the moment the pool can serve again
565
+ * without a probe. Null otherwise — including a pool mixing parked seats
566
+ * with an auth-cooling or token-expired one, which is not "all seats over
567
+ * their windows" and must not be reported (or cooled) as if it were; those
568
+ * pools stay on the existing unavailable handling (dario#1244, and the
569
+ * review on dario#1254 that caught the mixed case).
570
+ */
571
+ parkedUntil(now = Date.now()) {
572
+ if (this.accounts.size === 0)
573
+ return null;
574
+ const all = [...this.accounts.values()];
575
+ if (!all.every(a => isParkedInLiveWindow(a, now)))
576
+ return null;
577
+ return Math.min(...all.map(a => a.rateLimit.reset * 1000));
578
+ }
579
+ /** Seats currently parked inside a live window (dario#1244). */
580
+ parkedCount(now = Date.now()) {
581
+ return [...this.accounts.values()].filter(a => isParkedInLiveWindow(a, now)).length;
475
582
  }
476
583
  /**
477
584
  * Select with session stickiness. If `stickyKey` is already bound to a
@@ -588,8 +695,13 @@ export class AccountPool {
588
695
  }
589
696
  return pickMaxHeadroom(eligible, family);
590
697
  }
591
- if (candidates.length > 0) {
592
- return candidates.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
698
+ // Mid-flight: the seats a 429 could still hand this request to. A seat
699
+ // parked inside a live window is not one of them — on the dario#1244
700
+ // gateway every request walked all six parked seats, six guaranteed 429s
701
+ // a request. Cool-downs are skipped for the same reason.
702
+ const probeable = candidates.filter(a => !isInAuthCooldown(a, now) && !isParkedInLiveWindow(a, now));
703
+ if (probeable.length > 0) {
704
+ return probeable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
593
705
  }
594
706
  return null;
595
707
  }
@@ -598,6 +710,7 @@ export class AccountPool {
598
710
  if (!account)
599
711
  return;
600
712
  account.rateLimit = snapshot;
713
+ account.adoptedFrom = undefined;
601
714
  account.requestCount++;
602
715
  }
603
716
  /**
@@ -614,10 +727,37 @@ export class AccountPool {
614
727
  const now = snapshot.updatedAt || Date.now();
615
728
  const wasParked = account.rateLimit.status === 'rejected' && !rateLimitWindowPassed(account.rateLimit, now);
616
729
  account.rateLimit = { ...snapshot, status: 'rejected' };
730
+ account.adoptedFrom = undefined;
617
731
  account.rejectedCount++;
618
732
  account.lastRejectedAt = now;
619
733
  return !wasParked;
620
734
  }
735
+ /**
736
+ * Record the organization a response said this seat belongs to. Returns
737
+ * true when it is news — the first observation, or a change (an alias
738
+ * re-granted on another organization) — so the caller persists it once.
739
+ */
740
+ noteOrganization(alias, organizationId) {
741
+ const account = this.accounts.get(alias);
742
+ if (!account || !organizationId || account.organizationId === organizationId)
743
+ return false;
744
+ account.organizationId = organizationId;
745
+ return true;
746
+ }
747
+ /**
748
+ * Take a peer instance's reading of `alias` (pool-sync.ts): its snapshot
749
+ * replaces ours, `rejected` parks the seat on it. Counters are left alone
750
+ * — a request the peer served or a 429 it took are the peer's facts — and
751
+ * `adoptedFrom` records whose reading this is. False for an unknown alias.
752
+ */
753
+ adoptSnapshot(alias, snapshot, rejected, from) {
754
+ const account = this.accounts.get(alias);
755
+ if (!account)
756
+ return false;
757
+ account.rateLimit = rejected ? { ...snapshot, status: 'rejected' } : { ...snapshot };
758
+ account.adoptedFrom = from;
759
+ return true;
760
+ }
621
761
  updateTokens(alias, accessToken, refreshToken, expiresAt) {
622
762
  const account = this.accounts.get(alias);
623
763
  if (!account)
@@ -737,6 +877,7 @@ export function reconcilePoolAccounts(pool, accounts) {
737
877
  deviceId: a.deviceId,
738
878
  accountUuid: a.accountUuid,
739
879
  grantedAt: a.grantedAt,
880
+ organizationId: a.organizationId,
740
881
  });
741
882
  }
742
883
  for (const existing of pool.all()) {
package/dist/proxy.d.ts CHANGED
@@ -345,12 +345,25 @@ interface ProxyOptions {
345
345
  * `--pool-strategy` / `DARIO_POOL_STRATEGY` / config `pool.strategy`.
346
346
  */
347
347
  poolStrategy?: string;
348
+ /**
349
+ * Share rate-limit readings and sticky bindings with other instances
350
+ * through the refresh-lock service (pool-sync.ts). Needs
351
+ * `DARIO_REFRESH_LOCK_URL` / `DARIO_REFRESH_LOCK_TOKEN`. Off by default.
352
+ */
353
+ poolSharedState?: boolean;
354
+ /** How often to pull peers' readings, ms. Default 2000. */
355
+ poolSharedStateIntervalMs?: number;
348
356
  /** Max concurrent in-flight requests. Default 10. dario#80. */
349
357
  maxConcurrent?: number;
350
358
  /** Max requests buffered waiting for a concurrency slot. Default 128. dario#80. */
351
359
  maxQueued?: number;
352
360
  /** Max ms a queued request waits before it times out with 504. Default 60000. dario#80. */
353
361
  queueTimeoutMs?: number;
362
+ /**
363
+ * Max in-flight requests per consumer (`x-dario-consumer` header). A
364
+ * consumer at its cap waits while others keep flowing. 0 / unset = off.
365
+ */
366
+ maxConcurrentPerConsumer?: number;
354
367
  /**
355
368
  * Max ms before the upstream fetch is aborted. Default 300000 (5 min,
356
369
  * matching the Anthropic SDK). Injectable so tests can exercise the
@@ -545,6 +558,7 @@ export interface ProxyLogEntry {
545
558
  claim?: string;
546
559
  bucket?: string;
547
560
  account?: string;
561
+ consumer?: string;
548
562
  client?: string;
549
563
  preserve_tools?: boolean;
550
564
  stream?: boolean;