@askalf/dario 5.5.29 → 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.
@@ -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
@@ -92,6 +92,33 @@ export interface PoolAccount {
92
92
  }
93
93
  export declare function authCooldownMs(consecutiveFailures: number): number;
94
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;
95
122
  export interface PoolStatus {
96
123
  accounts: number;
97
124
  healthy: number;
package/dist/pool.js CHANGED
@@ -78,6 +78,41 @@ export function isInAuthCooldown(account, now = Date.now()) {
78
78
  const cooldown = authCooldownMs(account.consecutiveAuthFailures);
79
79
  return now - account.lastAuthFailureAt < cooldown;
80
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
+ }
81
116
  /**
82
117
  * Resolve the pool strategy from an explicit value (CLI flag / config file,
83
118
  * already precedence-merged by the caller) with `DARIO_POOL_STRATEGY` as
@@ -313,9 +348,7 @@ export class AccountPool {
313
348
  return null;
314
349
  const now = Date.now();
315
350
  const all = [...this.accounts.values()];
316
- const eligible = all.filter(a => a.rateLimit.status !== 'rejected' &&
317
- a.expiresAt > now + 30_000 &&
318
- !isInAuthCooldown(a, now));
351
+ const eligible = all.filter(a => isAccountEligible(a, now));
319
352
  if (eligible.length > 0) {
320
353
  if (this.strategy === 'fill-first') {
321
354
  const first = pickFillFirst(eligible, family);
@@ -366,9 +399,7 @@ export class AccountPool {
366
399
  if (binding) {
367
400
  const bound = this.accounts.get(binding.alias);
368
401
  if (bound
369
- && bound.rateLimit.status !== 'rejected'
370
- && bound.expiresAt > now + 30_000
371
- && !isInAuthCooldown(bound, now)
402
+ && isAccountEligible(bound, now)
372
403
  && computeHeadroom(bound.rateLimit, family) > POOL_HEADROOM_FLOOR) {
373
404
  // Refresh the idle timer. A session that keeps taking turns must never
374
405
  // be reaped or rebound while active — that would strand its warm prompt
@@ -446,9 +477,7 @@ export class AccountPool {
446
477
  return null;
447
478
  const now = Date.now();
448
479
  const candidates = [...this.accounts.values()].filter(a => !excluded.has(a.alias));
449
- const eligible = candidates.filter(a => a.rateLimit.status !== 'rejected' &&
450
- a.expiresAt > now + 30_000 &&
451
- !isInAuthCooldown(a, now));
480
+ const eligible = candidates.filter(a => isAccountEligible(a, now));
452
481
  if (eligible.length > 0) {
453
482
  // Fill-first failover keeps the fill order: the next account tried
454
483
  // after a 429 is the next alias in line, not the max-headroom seat —
@@ -496,9 +525,7 @@ export class AccountPool {
496
525
  status() {
497
526
  const all = this.all();
498
527
  const now = Date.now();
499
- const healthy = all.filter(a => a.rateLimit.status !== 'rejected' &&
500
- a.expiresAt > now + 30_000 &&
501
- !isInAuthCooldown(a, now));
528
+ const healthy = all.filter(a => isAccountEligible(a, now));
502
529
  // Status is a pool-wide aggregate; family-agnostic. Per-model
503
530
  // headroom is request-context-specific and only meaningful at
504
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, utilFreshness } 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.5.29",
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": {