@askalf/dario 5.5.28 → 5.5.30

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.
@@ -77,6 +77,17 @@ export interface AdminAccountRecord {
77
77
  export interface AdminAccountLive {
78
78
  util5h: number;
79
79
  util7d: number;
80
+ /**
81
+ * When `util5h` / `util7d` were last observed, epoch ms — or `null` if this
82
+ * account has never served a response. The utilisation figures are a
83
+ * snapshot of the last response the account served, not a live gauge: while
84
+ * an account is parked nothing refreshes them, so without a timestamp a
85
+ * consumer cannot tell a current reading from one frozen minutes ago
86
+ * (dario#1032).
87
+ */
88
+ lastObservedAt: number | null;
89
+ /** Age of that reading in ms, or `null` when never observed. */
90
+ utilAgeMs: number | null;
80
91
  claim: string;
81
92
  status: string;
82
93
  requestCount: number;
@@ -82,6 +82,19 @@ export interface HealthResponse {
82
82
  export interface PoolAccountStatusLike {
83
83
  expiresAt: number;
84
84
  inAuthCooldown: boolean;
85
+ /**
86
+ * Why the ROUTER cannot serve from this account, or null when it can —
87
+ * `accountIneligibility()` in pool.ts, computed by the caller (dario#1030).
88
+ *
89
+ * Required, deliberately. This surface previously derived usability itself
90
+ * from `inAuthCooldown` alone, which is a SUBSET of what select() filters on:
91
+ * a pool with every token expired, or every account rate-limited, reported
92
+ * `healthy` / `authenticated: true` while every request it served failed.
93
+ * The field is passed in rather than computed here so this module stays free
94
+ * of pool machinery and unit-testable as a pure function — and required so a
95
+ * caller cannot silently reintroduce the subset.
96
+ */
97
+ ineligible: string | null;
85
98
  }
86
99
  export interface PoolDerivedStatus {
87
100
  authenticated: boolean;
@@ -14,6 +14,17 @@
14
14
  * The HTTP status (200 healthy / 503 degraded) is identical either way, so external
15
15
  * uptime monitoring that keys on the status code is unaffected.
16
16
  */
17
+ /**
18
+ * Reason -> operator-facing summary. Keyed by `accountIneligibility()`'s return
19
+ * values; declared here rather than imported so this module stays free of pool
20
+ * machinery (see the file header). An unrecognised reason falls through to a
21
+ * generic line rather than being dropped.
22
+ */
23
+ const POOL_DEAD_SUMMARY = {
24
+ 'rate-limited': 'all accounts rate-limited',
25
+ 'token-expired': 'all tokens expired — run `dario login`',
26
+ 'auth-cooldown': 'all accounts in auth-cooldown',
27
+ };
17
28
  function formatMsLeft(ms) {
18
29
  const clamped = Math.max(0, ms);
19
30
  return `${Math.floor(clamped / 3_600_000)}h ${Math.floor((clamped % 3_600_000) / 60_000)}m`;
@@ -33,17 +44,25 @@ export function derivePoolStatus(accounts, now, adminEnabled) {
33
44
  : 'no accounts yet — run `dario accounts add <alias>`',
34
45
  };
35
46
  }
36
- const usable = accounts.filter((a) => !a.inAuthCooldown);
47
+ // Ask exactly the question the router asks. Anything select() would refuse
48
+ // to serve from is unusable here too, whatever the reason.
49
+ const usable = accounts.filter((a) => a.ineligible === null);
37
50
  if (usable.length === 0) {
38
- // Every account is routing-excluded after upstream auth failures — the
39
- // next request will fail, which is the deadness /health exists to signal.
51
+ // No account can serve the next request — the deadness /health exists to
52
+ // signal. Name the reason: an operator seeing 'broken' needs to know
53
+ // whether to run `dario login`, wait out a rate limit, or fix credentials,
54
+ // and those have nothing in common but the verdict.
55
+ const reasons = new Set(accounts.map((a) => a.ineligible).filter((r) => r !== null));
56
+ const summary = reasons.size === 1
57
+ ? POOL_DEAD_SUMMARY[[...reasons][0]] ?? `all accounts unusable: ${[...reasons][0]}`
58
+ : `no account can serve (${[...reasons].sort().join(', ')})`;
40
59
  return {
41
60
  authenticated: false,
42
61
  status: 'broken',
43
62
  mode: 'pool',
44
63
  accounts: accounts.length,
45
64
  expiresAt: Math.min(...accounts.map((a) => a.expiresAt)),
46
- expiresIn: 'all accounts in auth-cooldown',
65
+ expiresIn: summary,
47
66
  };
48
67
  }
49
68
  // Earliest expiry among USABLE accounts — the pool's background refresh
package/dist/pool.d.ts CHANGED
@@ -40,6 +40,31 @@ export interface RateLimitSnapshot {
40
40
  updatedAt: number;
41
41
  }
42
42
  export declare const EMPTY_SNAPSHOT: RateLimitSnapshot;
43
+ /** Freshness of an account's utilisation reading — see `utilFreshness`. */
44
+ export interface UtilFreshness {
45
+ /** When util5h/util7d were last observed, epoch ms; null if never. */
46
+ lastObservedAt: number | null;
47
+ /** Age of that reading in ms; null if never observed. */
48
+ utilAgeMs: number | null;
49
+ }
50
+ /**
51
+ * Derive how old an account's utilisation reading is (dario#1032).
52
+ *
53
+ * `util5h` / `util7d` are a SNAPSHOT of the last response the account served.
54
+ * They do not tick on their own, and nothing refreshes them while an account is
55
+ * parked (rejected, or in auth cooldown) — so they stay frozen at whatever they
56
+ * read at the moment it was parked. The pool does return parked accounts to
57
+ * service on its own and the value corrects itself when it does, which is what
58
+ * makes this a REPORTING problem rather than a routing one: the payload carried
59
+ * no timestamp, so no consumer could tell a current reading from one frozen
60
+ * minutes ago, and a dashboard rendered "5-hour window full" for an account
61
+ * that had since reset and was free.
62
+ *
63
+ * `updatedAt` was already on the snapshot; it was simply never surfaced. An
64
+ * `updatedAt` of 0 is EMPTY_SNAPSHOT's "never observed", which must report as
65
+ * null rather than as an age of ~56 years since epoch.
66
+ */
67
+ export declare function utilFreshness(rl: RateLimitSnapshot, now: number): UtilFreshness;
43
68
  export interface PoolAccount {
44
69
  alias: string;
45
70
  accessToken: string;
@@ -67,6 +92,33 @@ export interface PoolAccount {
67
92
  }
68
93
  export declare function authCooldownMs(consecutiveFailures: number): number;
69
94
  export declare function isInAuthCooldown(account: PoolAccount, now?: number): boolean;
95
+ /**
96
+ * How long before a token's stated expiry the router stops trusting it. A
97
+ * request selected at T must still be valid when it reaches Anthropic.
98
+ */
99
+ export declare const TOKEN_EXPIRY_MARGIN_MS = 30000;
100
+ /** Why the router cannot serve a request from an account. */
101
+ export type AccountIneligibility = 'rate-limited' | 'token-expired' | 'auth-cooldown';
102
+ /**
103
+ * The single answer to "can the router serve a request from this account, and
104
+ * if not, why not?" (dario#1030).
105
+ *
106
+ * This predicate was inline at four sites in this file and had been
107
+ * re-implemented, as a SUBSET, by a fifth reader in health-response.ts — which
108
+ * filtered on auth-cooldown alone. A pool whose tokens had all expired, or
109
+ * whose accounts were all rate-limited, therefore reported `healthy` /
110
+ * `authenticated: true` on /health while every request it served failed. That
111
+ * is the exact case /health exists to catch: a monitor watching it sees
112
+ * nothing, and `dario doctor` reads the same derivation.
113
+ *
114
+ * It returns the REASON rather than a boolean because the surfaces want to say
115
+ * why — /health's `expiresIn` line, the 503 body, and doctor's routing row all
116
+ * need to name the failure, and a boolean forces each of them to re-derive it
117
+ * and drift again.
118
+ */
119
+ export declare function accountIneligibility(account: PoolAccount, now?: number): AccountIneligibility | null;
120
+ /** Boolean form of `accountIneligibility` — the router's eligibility filter. */
121
+ export declare function isAccountEligible(account: PoolAccount, now?: number): boolean;
70
122
  export interface PoolStatus {
71
123
  accounts: number;
72
124
  healthy: number;
package/dist/pool.js CHANGED
@@ -34,6 +34,30 @@ export const EMPTY_SNAPSHOT = {
34
34
  fallbackPct: 0,
35
35
  updatedAt: 0,
36
36
  };
37
+ /**
38
+ * Derive how old an account's utilisation reading is (dario#1032).
39
+ *
40
+ * `util5h` / `util7d` are a SNAPSHOT of the last response the account served.
41
+ * They do not tick on their own, and nothing refreshes them while an account is
42
+ * parked (rejected, or in auth cooldown) — so they stay frozen at whatever they
43
+ * read at the moment it was parked. The pool does return parked accounts to
44
+ * service on its own and the value corrects itself when it does, which is what
45
+ * makes this a REPORTING problem rather than a routing one: the payload carried
46
+ * no timestamp, so no consumer could tell a current reading from one frozen
47
+ * minutes ago, and a dashboard rendered "5-hour window full" for an account
48
+ * that had since reset and was free.
49
+ *
50
+ * `updatedAt` was already on the snapshot; it was simply never surfaced. An
51
+ * `updatedAt` of 0 is EMPTY_SNAPSHOT's "never observed", which must report as
52
+ * null rather than as an age of ~56 years since epoch.
53
+ */
54
+ export function utilFreshness(rl, now) {
55
+ const lastObservedAt = rl.updatedAt || null;
56
+ return {
57
+ lastObservedAt,
58
+ utilAgeMs: lastObservedAt === null ? null : Math.max(0, now - lastObservedAt),
59
+ };
60
+ }
37
61
  /**
38
62
  * Cool-down schedule after auth failures. First failure: 60s. Each
39
63
  * consecutive failure doubles the window up to 30 minutes. Cleared
@@ -54,6 +78,41 @@ export function isInAuthCooldown(account, now = Date.now()) {
54
78
  const cooldown = authCooldownMs(account.consecutiveAuthFailures);
55
79
  return now - account.lastAuthFailureAt < cooldown;
56
80
  }
81
+ /**
82
+ * How long before a token's stated expiry the router stops trusting it. A
83
+ * request selected at T must still be valid when it reaches Anthropic.
84
+ */
85
+ export const TOKEN_EXPIRY_MARGIN_MS = 30_000;
86
+ /**
87
+ * The single answer to "can the router serve a request from this account, and
88
+ * if not, why not?" (dario#1030).
89
+ *
90
+ * This predicate was inline at four sites in this file and had been
91
+ * re-implemented, as a SUBSET, by a fifth reader in health-response.ts — which
92
+ * filtered on auth-cooldown alone. A pool whose tokens had all expired, or
93
+ * whose accounts were all rate-limited, therefore reported `healthy` /
94
+ * `authenticated: true` on /health while every request it served failed. That
95
+ * is the exact case /health exists to catch: a monitor watching it sees
96
+ * nothing, and `dario doctor` reads the same derivation.
97
+ *
98
+ * It returns the REASON rather than a boolean because the surfaces want to say
99
+ * why — /health's `expiresIn` line, the 503 body, and doctor's routing row all
100
+ * need to name the failure, and a boolean forces each of them to re-derive it
101
+ * and drift again.
102
+ */
103
+ export function accountIneligibility(account, now = Date.now()) {
104
+ if (account.rateLimit.status === 'rejected')
105
+ return 'rate-limited';
106
+ if (account.expiresAt <= now + TOKEN_EXPIRY_MARGIN_MS)
107
+ return 'token-expired';
108
+ if (isInAuthCooldown(account, now))
109
+ return 'auth-cooldown';
110
+ return null;
111
+ }
112
+ /** Boolean form of `accountIneligibility` — the router's eligibility filter. */
113
+ export function isAccountEligible(account, now = Date.now()) {
114
+ return accountIneligibility(account, now) === null;
115
+ }
57
116
  /**
58
117
  * Resolve the pool strategy from an explicit value (CLI flag / config file,
59
118
  * already precedence-merged by the caller) with `DARIO_POOL_STRATEGY` as
@@ -289,9 +348,7 @@ export class AccountPool {
289
348
  return null;
290
349
  const now = Date.now();
291
350
  const all = [...this.accounts.values()];
292
- const eligible = all.filter(a => a.rateLimit.status !== 'rejected' &&
293
- a.expiresAt > now + 30_000 &&
294
- !isInAuthCooldown(a, now));
351
+ const eligible = all.filter(a => isAccountEligible(a, now));
295
352
  if (eligible.length > 0) {
296
353
  if (this.strategy === 'fill-first') {
297
354
  const first = pickFillFirst(eligible, family);
@@ -342,9 +399,7 @@ export class AccountPool {
342
399
  if (binding) {
343
400
  const bound = this.accounts.get(binding.alias);
344
401
  if (bound
345
- && bound.rateLimit.status !== 'rejected'
346
- && bound.expiresAt > now + 30_000
347
- && !isInAuthCooldown(bound, now)
402
+ && isAccountEligible(bound, now)
348
403
  && computeHeadroom(bound.rateLimit, family) > POOL_HEADROOM_FLOOR) {
349
404
  // Refresh the idle timer. A session that keeps taking turns must never
350
405
  // be reaped or rebound while active — that would strand its warm prompt
@@ -422,9 +477,7 @@ export class AccountPool {
422
477
  return null;
423
478
  const now = Date.now();
424
479
  const candidates = [...this.accounts.values()].filter(a => !excluded.has(a.alias));
425
- const eligible = candidates.filter(a => a.rateLimit.status !== 'rejected' &&
426
- a.expiresAt > now + 30_000 &&
427
- !isInAuthCooldown(a, now));
480
+ const eligible = candidates.filter(a => isAccountEligible(a, now));
428
481
  if (eligible.length > 0) {
429
482
  // Fill-first failover keeps the fill order: the next account tried
430
483
  // after a 429 is the next alias in line, not the max-headroom seat —
@@ -472,9 +525,7 @@ export class AccountPool {
472
525
  status() {
473
526
  const all = this.all();
474
527
  const now = Date.now();
475
- const healthy = all.filter(a => a.rateLimit.status !== 'rejected' &&
476
- a.expiresAt > now + 30_000 &&
477
- !isInAuthCooldown(a, now));
528
+ const healthy = all.filter(a => isAccountEligible(a, now));
478
529
  // Status is a pool-wide aggregate; family-agnostic. Per-model
479
530
  // headroom is request-context-specific and only meaningful at
480
531
  // select() time.
package/dist/proxy.js CHANGED
@@ -12,7 +12,7 @@ import { darioVersion } from './version.js';
12
12
  import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
13
13
  import { stampCch, hasCchSeed } from './cch.js';
14
14
  import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
15
- import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, reconcilePoolAccounts, resolvePoolStrategy } from './pool.js';
15
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness } from './pool.js';
16
16
  import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS } from './analytics.js';
17
17
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
18
18
  import { notify as osNotify } from './notify.js';
@@ -1677,7 +1677,14 @@ export async function startProxy(opts = {}) {
1677
1677
  // made the TUI claim the proxy was down).
1678
1678
  async function currentStatus() {
1679
1679
  const now = Date.now();
1680
- return derivePoolStatus(pool.all().map((a) => ({ expiresAt: a.expiresAt, inAuthCooldown: isInAuthCooldown(a, now) })), now, adminEnabled);
1680
+ return derivePoolStatus(
1681
+ // `ineligible` carries the ROUTER's verdict, so /health cannot answer a
1682
+ // narrower question than select() does (#1030).
1683
+ pool.all().map((a) => ({
1684
+ expiresAt: a.expiresAt,
1685
+ inAuthCooldown: isInAuthCooldown(a, now),
1686
+ ineligible: accountIneligibility(a, now),
1687
+ })), now, adminEnabled);
1681
1688
  }
1682
1689
  // Model catalog wiring — /v1/models serves the upstream-autodetected set,
1683
1690
  // authenticated the same way the request path is (per-token API key when
@@ -1834,6 +1841,10 @@ export async function startProxy(opts = {}) {
1834
1841
  snap.set(a.alias, {
1835
1842
  util5h: a.rateLimit.util5h,
1836
1843
  util7d: a.rateLimit.util7d,
1844
+ // Same freshness fields GET /accounts exposes (#1032) — this
1845
+ // surface documents itself as reporting the same snapshot, so it
1846
+ // must not be the one place a stale reading still looks current.
1847
+ ...utilFreshness(a.rateLimit, snapNow),
1837
1848
  claim: a.rateLimit.claim,
1838
1849
  status: isInAuthCooldown(a, snapNow) ? 'auth-cooldown' : a.rateLimit.status,
1839
1850
  requestCount: a.requestCount,
@@ -1913,10 +1924,25 @@ export async function startProxy(opts = {}) {
1913
1924
  const cooldownMs = inCooldown && a.lastAuthFailureAt
1914
1925
  ? Math.max(0, authCooldownMs(a.consecutiveAuthFailures) - (now - a.lastAuthFailureAt))
1915
1926
  : 0;
1927
+ // Freshness of the utilisation reading (#1032). util5h/util7d are a
1928
+ // SNAPSHOT of the last response this account served — they do not tick
1929
+ // on their own. While an account is parked (rejected, or in auth
1930
+ // cooldown) nothing refreshes them, so they stay frozen at whatever
1931
+ // they read at the moment it was parked, and a consumer sees "5-hour
1932
+ // window full" for an account that has since reset and is free.
1933
+ //
1934
+ // The pool does return parked accounts to service on its own and the
1935
+ // value corrects itself the moment it does, so this is a reporting
1936
+ // problem, not a routing one: nothing downstream could tell a current
1937
+ // reading from one frozen minutes ago, because the payload carried no
1938
+ // timestamp at all. `updatedAt` was already on the snapshot; it was
1939
+ // simply never surfaced. null means "never observed" (no response has
1940
+ // been served on this account yet) rather than "observed at epoch 0".
1916
1941
  return {
1917
1942
  alias: a.alias,
1918
1943
  util5h: a.rateLimit.util5h,
1919
1944
  util7d: a.rateLimit.util7d,
1945
+ ...utilFreshness(a.rateLimit, now),
1920
1946
  claim: a.rateLimit.claim,
1921
1947
  status: inCooldown ? 'auth-cooldown' : a.rateLimit.status,
1922
1948
  requestCount: a.requestCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.5.28",
3
+ "version": "5.5.30",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {