@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.
- package/dist/admin-api.d.ts +11 -0
- package/dist/health-response.d.ts +13 -0
- package/dist/health-response.js +23 -4
- package/dist/pool.d.ts +52 -0
- package/dist/pool.js +63 -12
- package/dist/proxy.js +28 -2
- package/package.json +1 -1
package/dist/admin-api.d.ts
CHANGED
|
@@ -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;
|
package/dist/health-response.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
39
|
-
//
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
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": {
|