@askalf/dario 6.0.32 → 6.0.34

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
@@ -65,6 +65,54 @@ export interface UtilFreshness {
65
65
  * null rather than as an age of ~56 years since epoch.
66
66
  */
67
67
  export declare function utilFreshness(rl: RateLimitSnapshot, now: number): UtilFreshness;
68
+ /** When an account's rate-limit window rolls over — see `rateLimitWindow`. */
69
+ export interface RateLimitWindow {
70
+ /**
71
+ * Epoch ms the window resets at, from `anthropic-ratelimit-unified-reset`;
72
+ * null when no response on this account has stated one.
73
+ */
74
+ resetAt: number | null;
75
+ /** Ms until that reset, floored at 0 once it has passed; null when unknown. */
76
+ resetInMs: number | null;
77
+ }
78
+ /**
79
+ * The reset moment of the window an account's last reading was measured
80
+ * against (dario#1244). The snapshot has carried `reset` since the header was
81
+ * first parsed and routing has expired rejections on it since #1232, but no
82
+ * operator surface showed it: a seat read `status: rejected` with nothing
83
+ * saying until when, and `requestCount: 0` beside it (a 429 serves nothing,
84
+ * so the attempt was never counted) made the rejection look like one dario
85
+ * had made up. For a `rejected` seat this is when the rejection lifts; for
86
+ * an `allowed` one, when its representative window rolls. The header is
87
+ * epoch SECONDS; both fields here are milliseconds, like `expiresInMs` and
88
+ * `utilAgeMs`.
89
+ */
90
+ export declare function rateLimitWindow(rl: RateLimitSnapshot, now: number): RateLimitWindow;
91
+ /**
92
+ * One line for a log or a doctor row:
93
+ * `5h 104%, 7d 25%, claim five_hour, resets in 37m`.
94
+ */
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;
68
116
  export interface PoolAccount {
69
117
  alias: string;
70
118
  accessToken: string;
@@ -73,6 +121,32 @@ export interface PoolAccount {
73
121
  identity: AccountIdentity;
74
122
  rateLimit: RateLimitSnapshot;
75
123
  requestCount: number;
124
+ /**
125
+ * Upstream 429s this account has answered. `requestCount` counts requests
126
+ * the account SERVED, and a 429 served nothing — so a seat parked on its
127
+ * first attempt read `requestCount: 0` next to `status: rejected`, as if
128
+ * dario had rejected a seat it never called (dario#1244). This is the field
129
+ * that says it was tried.
130
+ */
131
+ rejectedCount: number;
132
+ /** Epoch ms of the most recent 429 on this account; undefined if never. */
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;
76
150
  /** Epoch ms of the OAuth grant (refresh-grant.ts); undefined when unknown. */
77
151
  grantedAt?: number;
78
152
  /**
@@ -236,6 +310,7 @@ export declare class AccountPool {
236
310
  deviceId: string;
237
311
  accountUuid: string;
238
312
  grantedAt?: number;
313
+ organizationId?: string;
239
314
  }): void;
240
315
  remove(alias: string): boolean;
241
316
  get size(): number;
@@ -304,7 +379,27 @@ export declare class AccountPool {
304
379
  /** Select the next-best account, excluding the given set of aliases. */
305
380
  selectExcluding(excluded: Set<string>, family?: string | null): PoolAccount | null;
306
381
  updateRateLimits(alias: string, snapshot: RateLimitSnapshot): void;
307
- markRejected(alias: string, snapshot: RateLimitSnapshot): void;
382
+ /**
383
+ * Park `alias` on an upstream 429. Returns true when this takes a seat OUT
384
+ * of rotation — the first 429 of a window — and false when the seat was
385
+ * already parked inside a live window: the all-exhausted fallback in
386
+ * `select()` re-probes parked seats, so a pool with nothing left can 429
387
+ * the same seat many times, and only the transition is worth a log line.
388
+ */
389
+ markRejected(alias: string, snapshot: RateLimitSnapshot): boolean;
390
+ /**
391
+ * Record the organization a response said this seat belongs to. Returns
392
+ * true when it is news — the first observation, or a change (an alias
393
+ * re-granted on another organization) — so the caller persists it once.
394
+ */
395
+ noteOrganization(alias: string, organizationId: string): boolean;
396
+ /**
397
+ * Take a peer instance's reading of `alias` (pool-sync.ts): its snapshot
398
+ * replaces ours, `rejected` parks the seat on it. Counters are left alone
399
+ * — a request the peer served or a 429 it took are the peer's facts — and
400
+ * `adoptedFrom` records whose reading this is. False for an unknown alias.
401
+ */
402
+ adoptSnapshot(alias: string, snapshot: RateLimitSnapshot, rejected: boolean, from: string): boolean;
308
403
  updateTokens(alias: string, accessToken: string, refreshToken: string, expiresAt: number): void;
309
404
  get(alias: string): PoolAccount | undefined;
310
405
  all(): PoolAccount[];
@@ -327,6 +422,7 @@ export interface ReconcilableAccount {
327
422
  deviceId: string;
328
423
  accountUuid: string;
329
424
  grantedAt?: number;
425
+ organizationId?: string;
330
426
  }
331
427
  /**
332
428
  * Reconcile a live pool against the current on-disk account set: add or refresh
package/dist/pool.js CHANGED
@@ -58,6 +58,97 @@ export function utilFreshness(rl, now) {
58
58
  utilAgeMs: lastObservedAt === null ? null : Math.max(0, now - lastObservedAt),
59
59
  };
60
60
  }
61
+ /**
62
+ * The reset moment of the window an account's last reading was measured
63
+ * against (dario#1244). The snapshot has carried `reset` since the header was
64
+ * first parsed and routing has expired rejections on it since #1232, but no
65
+ * operator surface showed it: a seat read `status: rejected` with nothing
66
+ * saying until when, and `requestCount: 0` beside it (a 429 serves nothing,
67
+ * so the attempt was never counted) made the rejection look like one dario
68
+ * had made up. For a `rejected` seat this is when the rejection lifts; for
69
+ * an `allowed` one, when its representative window rolls. The header is
70
+ * epoch SECONDS; both fields here are milliseconds, like `expiresInMs` and
71
+ * `utilAgeMs`.
72
+ */
73
+ export function rateLimitWindow(rl, now) {
74
+ if (!(rl.reset > 0))
75
+ return { resetAt: null, resetInMs: null };
76
+ const resetAt = rl.reset * 1000;
77
+ return { resetAt, resetInMs: Math.max(0, resetAt - now) };
78
+ }
79
+ /**
80
+ * One line for a log or a doctor row:
81
+ * `5h 104%, 7d 25%, claim five_hour, resets in 37m`.
82
+ */
83
+ export function describeRateLimitSnapshot(rl, now = Date.now()) {
84
+ const pct = (n) => `${Math.round(n * 100)}%`;
85
+ const { resetInMs } = rateLimitWindow(rl, now);
86
+ const reset = resetInMs === null ? 'no reset stated'
87
+ : resetInMs === 0 ? 'window already rolled'
88
+ : `resets in ${formatDurationMs(resetInMs)}`;
89
+ return `5h ${pct(rl.util5h)}, 7d ${pct(rl.util7d)}, claim ${rl.claim}, ${reset}`;
90
+ }
91
+ function formatDurationMs(ms) {
92
+ const totalMins = Math.max(1, Math.round(ms / 60_000));
93
+ const h = Math.floor(totalMins / 60);
94
+ const m = totalMins % 60;
95
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
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
+ }
61
152
  /**
62
153
  * Cool-down schedule after auth failures. First failure: 60s. Each
63
154
  * consecutive failure doubles the window up to 30 minutes. Cleared
@@ -318,21 +409,38 @@ export class AccountPool {
318
409
  }
319
410
  add(alias, opts) {
320
411
  const existing = this.accounts.get(alias);
412
+ // A record whose grantedAt differs from the live entry's is a NEW grant
413
+ // under this alias — a re-login, possibly on a different organization
414
+ // with its own windows. The live state describes the old credential (its
415
+ // rejection and reading, its auth streak, its identity), so it starts
416
+ // fresh (dario#1244): before this, a seat re-granted to clear
417
+ // `auth-cooldown` stayed cooling until the old streak's timer ran out,
418
+ // and one re-granted on another organization stayed parked on the old
419
+ // organization's window. A reconcile carrying the same grant — a token
420
+ // refresh, an admin change to another seat, a peer instance's rotation in
421
+ // HA — keeps the live state as before. So does a record with no grantedAt
422
+ // at all: it cannot be told apart from the same grant.
423
+ const regranted = existing !== undefined && opts.grantedAt !== undefined && opts.grantedAt !== existing.grantedAt;
424
+ const keep = regranted ? undefined : existing;
321
425
  this.accounts.set(alias, {
322
426
  alias,
323
427
  accessToken: opts.accessToken,
324
428
  refreshToken: opts.refreshToken,
325
429
  expiresAt: opts.expiresAt,
326
- grantedAt: opts.grantedAt ?? existing?.grantedAt,
327
- identity: existing?.identity ?? {
430
+ grantedAt: opts.grantedAt ?? keep?.grantedAt,
431
+ organizationId: opts.organizationId ?? keep?.organizationId,
432
+ adoptedFrom: keep?.adoptedFrom,
433
+ identity: keep?.identity ?? {
328
434
  deviceId: opts.deviceId,
329
435
  accountUuid: opts.accountUuid,
330
436
  sessionId: randomUUID(),
331
437
  },
332
- rateLimit: existing?.rateLimit ?? { ...EMPTY_SNAPSHOT },
333
- requestCount: existing?.requestCount ?? 0,
334
- lastAuthFailureAt: existing?.lastAuthFailureAt,
335
- consecutiveAuthFailures: existing?.consecutiveAuthFailures ?? 0,
438
+ rateLimit: keep?.rateLimit ?? { ...EMPTY_SNAPSHOT },
439
+ requestCount: keep?.requestCount ?? 0,
440
+ rejectedCount: keep?.rejectedCount ?? 0,
441
+ lastRejectedAt: keep?.lastRejectedAt,
442
+ lastAuthFailureAt: keep?.lastAuthFailureAt,
443
+ consecutiveAuthFailures: keep?.consecutiveAuthFailures ?? 0,
336
444
  });
337
445
  }
338
446
  remove(alias) {
@@ -547,13 +655,53 @@ export class AccountPool {
547
655
  if (!account)
548
656
  return;
549
657
  account.rateLimit = snapshot;
658
+ account.adoptedFrom = undefined;
550
659
  account.requestCount++;
551
660
  }
661
+ /**
662
+ * Park `alias` on an upstream 429. Returns true when this takes a seat OUT
663
+ * of rotation — the first 429 of a window — and false when the seat was
664
+ * already parked inside a live window: the all-exhausted fallback in
665
+ * `select()` re-probes parked seats, so a pool with nothing left can 429
666
+ * the same seat many times, and only the transition is worth a log line.
667
+ */
552
668
  markRejected(alias, snapshot) {
553
669
  const account = this.accounts.get(alias);
554
670
  if (!account)
555
- return;
671
+ return false;
672
+ const now = snapshot.updatedAt || Date.now();
673
+ const wasParked = account.rateLimit.status === 'rejected' && !rateLimitWindowPassed(account.rateLimit, now);
556
674
  account.rateLimit = { ...snapshot, status: 'rejected' };
675
+ account.adoptedFrom = undefined;
676
+ account.rejectedCount++;
677
+ account.lastRejectedAt = now;
678
+ return !wasParked;
679
+ }
680
+ /**
681
+ * Record the organization a response said this seat belongs to. Returns
682
+ * true when it is news — the first observation, or a change (an alias
683
+ * re-granted on another organization) — so the caller persists it once.
684
+ */
685
+ noteOrganization(alias, organizationId) {
686
+ const account = this.accounts.get(alias);
687
+ if (!account || !organizationId || account.organizationId === organizationId)
688
+ return false;
689
+ account.organizationId = organizationId;
690
+ return true;
691
+ }
692
+ /**
693
+ * Take a peer instance's reading of `alias` (pool-sync.ts): its snapshot
694
+ * replaces ours, `rejected` parks the seat on it. Counters are left alone
695
+ * — a request the peer served or a 429 it took are the peer's facts — and
696
+ * `adoptedFrom` records whose reading this is. False for an unknown alias.
697
+ */
698
+ adoptSnapshot(alias, snapshot, rejected, from) {
699
+ const account = this.accounts.get(alias);
700
+ if (!account)
701
+ return false;
702
+ account.rateLimit = rejected ? { ...snapshot, status: 'rejected' } : { ...snapshot };
703
+ account.adoptedFrom = from;
704
+ return true;
557
705
  }
558
706
  updateTokens(alias, accessToken, refreshToken, expiresAt) {
559
707
  const account = this.accounts.get(alias);
@@ -674,6 +822,7 @@ export function reconcilePoolAccounts(pool, accounts) {
674
822
  deviceId: a.deviceId,
675
823
  accountUuid: a.accountUuid,
676
824
  grantedAt: a.grantedAt,
825
+ organizationId: a.organizationId,
677
826
  });
678
827
  }
679
828
  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;