@askalf/dario 6.0.33 → 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/README.md +39 -59
- package/dist/accounts.d.ts +18 -0
- package/dist/accounts.js +14 -0
- package/dist/admin-api.d.ts +8 -0
- package/dist/admin-api.js +11 -1
- package/dist/analytics.d.ts +41 -1
- package/dist/analytics.js +65 -2
- package/dist/cli.js +94 -1
- package/dist/doctor-core.d.ts +15 -0
- package/dist/doctor-core.js +34 -0
- package/dist/pool-sync.d.ts +114 -0
- package/dist/pool-sync.js +200 -0
- package/dist/pool.d.ts +51 -0
- package/dist/pool.js +86 -0
- package/dist/proxy.d.ts +14 -0
- package/dist/proxy.js +117 -12
- package/dist/request-queue.d.ts +34 -4
- package/dist/request-queue.js +57 -11
- package/dist/tui/tabs/hits.js +2 -0
- package/docs/admin-api.md +5 -1
- package/docs/configuration.md +10 -0
- package/docs/integrations/openclaw-walkthrough.md +2 -2
- package/docs/multi-account-pool.md +26 -0
- package/docs/multi-instance.md +27 -6
- package/package.json +18 -12
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
|
|
@@ -373,6 +428,8 @@ export class AccountPool {
|
|
|
373
428
|
refreshToken: opts.refreshToken,
|
|
374
429
|
expiresAt: opts.expiresAt,
|
|
375
430
|
grantedAt: opts.grantedAt ?? keep?.grantedAt,
|
|
431
|
+
organizationId: opts.organizationId ?? keep?.organizationId,
|
|
432
|
+
adoptedFrom: keep?.adoptedFrom,
|
|
376
433
|
identity: keep?.identity ?? {
|
|
377
434
|
deviceId: opts.deviceId,
|
|
378
435
|
accountUuid: opts.accountUuid,
|
|
@@ -598,6 +655,7 @@ export class AccountPool {
|
|
|
598
655
|
if (!account)
|
|
599
656
|
return;
|
|
600
657
|
account.rateLimit = snapshot;
|
|
658
|
+
account.adoptedFrom = undefined;
|
|
601
659
|
account.requestCount++;
|
|
602
660
|
}
|
|
603
661
|
/**
|
|
@@ -614,10 +672,37 @@ export class AccountPool {
|
|
|
614
672
|
const now = snapshot.updatedAt || Date.now();
|
|
615
673
|
const wasParked = account.rateLimit.status === 'rejected' && !rateLimitWindowPassed(account.rateLimit, now);
|
|
616
674
|
account.rateLimit = { ...snapshot, status: 'rejected' };
|
|
675
|
+
account.adoptedFrom = undefined;
|
|
617
676
|
account.rejectedCount++;
|
|
618
677
|
account.lastRejectedAt = now;
|
|
619
678
|
return !wasParked;
|
|
620
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;
|
|
705
|
+
}
|
|
621
706
|
updateTokens(alias, accessToken, refreshToken, expiresAt) {
|
|
622
707
|
const account = this.accounts.get(alias);
|
|
623
708
|
if (!account)
|
|
@@ -737,6 +822,7 @@ export function reconcilePoolAccounts(pool, accounts) {
|
|
|
737
822
|
deviceId: a.deviceId,
|
|
738
823
|
accountUuid: a.accountUuid,
|
|
739
824
|
grantedAt: a.grantedAt,
|
|
825
|
+
organizationId: a.organizationId,
|
|
740
826
|
});
|
|
741
827
|
}
|
|
742
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;
|
package/dist/proxy.js
CHANGED
|
@@ -12,13 +12,14 @@ import { darioVersion } from './version.js';
|
|
|
12
12
|
import { buildCCRequest, applyCcPromptCaching, isGenuineCCClient, 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, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness, rateLimitWindow, describeRateLimitSnapshot } from './pool.js';
|
|
16
|
-
import {
|
|
15
|
+
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness, rateLimitWindow, describeRateLimitSnapshot, windowPeers, distinctWindows } from './pool.js';
|
|
16
|
+
import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
|
|
17
|
+
import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
|
|
17
18
|
import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
|
|
18
19
|
import { notify as osNotify } from './notify.js';
|
|
19
20
|
import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
|
|
20
21
|
import { resolveSeatPin, SEAT_PIN_HEADER, SEAT_PIN_TOKEN_HEADER } from './seat-pin.js';
|
|
21
|
-
import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool, mirrorLoginToCredentials } from './accounts.js';
|
|
22
|
+
import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool, mirrorLoginToCredentials, withObservedOrganization } from './accounts.js';
|
|
22
23
|
import { handleAdminRequest } from './admin-api.js';
|
|
23
24
|
import { createTokenBucket } from './rate-limit.js';
|
|
24
25
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
@@ -1328,9 +1329,45 @@ export async function startProxy(opts = {}) {
|
|
|
1328
1329
|
const accountsList = await loadAllAccounts();
|
|
1329
1330
|
const poolStrategy = resolvePoolStrategy(opts.poolStrategy);
|
|
1330
1331
|
const pool = new AccountPool(poolStrategy);
|
|
1332
|
+
// Two aliases reporting one window are one subscription counted twice
|
|
1333
|
+
// (dario#1244). Said once per pair, when the second reading arrives; the
|
|
1334
|
+
// listings carry it permanently as `sharesWindowWith`.
|
|
1335
|
+
const announcedWindowPairs = new Set();
|
|
1336
|
+
const announceWindowPeers = (alias) => {
|
|
1337
|
+
const now = Date.now();
|
|
1338
|
+
const seat = pool.get(alias);
|
|
1339
|
+
if (!seat)
|
|
1340
|
+
return;
|
|
1341
|
+
for (const peer of windowPeers(pool.all(), now).get(alias) ?? []) {
|
|
1342
|
+
const pair = [alias, peer].sort().join('|');
|
|
1343
|
+
if (announcedWindowPairs.has(pair))
|
|
1344
|
+
continue;
|
|
1345
|
+
announcedWindowPairs.add(pair);
|
|
1346
|
+
const windows = distinctWindows(pool.all(), now);
|
|
1347
|
+
console.error(`[dario] seats "${alias}" and "${peer}" report the same ${seat.rateLimit.claim} window (resets ${new Date(seat.rateLimit.reset * 1000).toISOString()}) — one subscription under two aliases; the pool has ${windows} distinct window${windows === 1 ? '' : 's'} across ${pool.size} seats`);
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
// Shared pool state across instances (docs/multi-instance.md): opt-in,
|
|
1351
|
+
// rides the refresh-lock service, fails open. Off → byte-identical to before.
|
|
1352
|
+
const lockUrl = process.env['DARIO_REFRESH_LOCK_URL'];
|
|
1353
|
+
const poolSync = opts.poolSharedState && lockUrl
|
|
1354
|
+
? new PoolSync(pool, {
|
|
1355
|
+
baseUrl: lockUrl,
|
|
1356
|
+
token: process.env['DARIO_REFRESH_LOCK_TOKEN'] ?? '',
|
|
1357
|
+
intervalMs: opts.poolSharedStateIntervalMs ?? DEFAULT_POOL_SYNC_INTERVAL_MS,
|
|
1358
|
+
log: (line) => console.error(line),
|
|
1359
|
+
})
|
|
1360
|
+
: null;
|
|
1361
|
+
if (opts.poolSharedState && !poolSync) {
|
|
1362
|
+
console.error('[dario] --pool-shared-state needs DARIO_REFRESH_LOCK_URL (the lock service carries the shared state) — running with this instance\'s own state');
|
|
1363
|
+
}
|
|
1331
1364
|
if (poolStrategy !== 'headroom') {
|
|
1332
1365
|
console.log(` Pool strategy: ${poolStrategy} (new conversations fill the alphabetically-first seat, spill at the 2% floor)`);
|
|
1333
1366
|
}
|
|
1367
|
+
if (poolSync) {
|
|
1368
|
+
console.log(` Pool shared state: on (instance ${poolSync.instance}, via ${lockUrl}, pulling peers every ${poolSync.intervalMs}ms; fails open)`);
|
|
1369
|
+
poolSync.start();
|
|
1370
|
+
}
|
|
1334
1371
|
// Per-model rate-limit bucket families seen during this proxy run. First-
|
|
1335
1372
|
// sight is logged once when verbose so a new Anthropic bucket (e.g. an
|
|
1336
1373
|
// eventual `7d_opus`) doesn't slip past unnoticed. Pure observability —
|
|
@@ -1398,6 +1435,7 @@ export async function startProxy(opts = {}) {
|
|
|
1398
1435
|
deviceId: acc.deviceId,
|
|
1399
1436
|
accountUuid: acc.accountUuid,
|
|
1400
1437
|
grantedAt: acc.grantedAt,
|
|
1438
|
+
organizationId: acc.organizationId,
|
|
1401
1439
|
});
|
|
1402
1440
|
}
|
|
1403
1441
|
// Startup self-heal (dario#790): eagerly refresh any account whose access
|
|
@@ -1418,7 +1456,9 @@ export async function startProxy(opts = {}) {
|
|
|
1418
1456
|
const saved = await loadAccount(acc.alias);
|
|
1419
1457
|
if (!saved)
|
|
1420
1458
|
return;
|
|
1421
|
-
|
|
1459
|
+
// The refresh's write carries the organization the seat was
|
|
1460
|
+
// observed on (dario#1244) — the one write that touches the record.
|
|
1461
|
+
const refreshed = await refreshAccountToken(withObservedOrganization(saved, acc.organizationId));
|
|
1422
1462
|
pool.updateTokens(acc.alias, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
|
|
1423
1463
|
// Mirror a refreshed `login` token back to credentials.json so the
|
|
1424
1464
|
// legacy file (and `dario doctor`) tracks the pool store (#808).
|
|
@@ -1470,7 +1510,9 @@ export async function startProxy(opts = {}) {
|
|
|
1470
1510
|
const saved = await loadAccount(acc.alias);
|
|
1471
1511
|
if (!saved)
|
|
1472
1512
|
continue;
|
|
1473
|
-
|
|
1513
|
+
// The refresh's write carries the organization the seat was
|
|
1514
|
+
// observed on (dario#1244) — the one write that touches the record.
|
|
1515
|
+
const refreshed = await refreshAccountToken(withObservedOrganization(saved, acc.organizationId));
|
|
1474
1516
|
pool.updateTokens(acc.alias, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
|
|
1475
1517
|
// Mirror a refreshed `login` token back to credentials.json so the
|
|
1476
1518
|
// legacy file (and `dario doctor`) tracks the pool store (#808).
|
|
@@ -1515,6 +1557,7 @@ export async function startProxy(opts = {}) {
|
|
|
1515
1557
|
deviceId: acc.deviceId,
|
|
1516
1558
|
accountUuid: acc.accountUuid,
|
|
1517
1559
|
grantedAt: acc.grantedAt,
|
|
1560
|
+
organizationId: acc.organizationId,
|
|
1518
1561
|
});
|
|
1519
1562
|
}
|
|
1520
1563
|
}
|
|
@@ -1616,6 +1659,7 @@ export async function startProxy(opts = {}) {
|
|
|
1616
1659
|
maxConcurrent: opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT,
|
|
1617
1660
|
maxQueued: opts.maxQueued ?? DEFAULT_MAX_QUEUED,
|
|
1618
1661
|
queueTimeoutMs: opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS,
|
|
1662
|
+
maxConcurrentPerConsumer: opts.maxConcurrentPerConsumer ?? 0,
|
|
1619
1663
|
});
|
|
1620
1664
|
const upstreamTimeoutMs = opts.upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_MS;
|
|
1621
1665
|
// Cache context-1m beta availability. Set false once per account after the
|
|
@@ -2093,6 +2137,7 @@ export async function startProxy(opts = {}) {
|
|
|
2093
2137
|
// just persisted metadata — the same snapshot GET /accounts exposes.
|
|
2094
2138
|
poolStatus: () => {
|
|
2095
2139
|
const snapNow = Date.now();
|
|
2140
|
+
const peers = windowPeers(pool.all(), snapNow);
|
|
2096
2141
|
const snap = new Map();
|
|
2097
2142
|
for (const a of pool.all()) {
|
|
2098
2143
|
snap.set(a.alias, {
|
|
@@ -2108,6 +2153,9 @@ export async function startProxy(opts = {}) {
|
|
|
2108
2153
|
requestCount: a.requestCount,
|
|
2109
2154
|
rejectedCount: a.rejectedCount,
|
|
2110
2155
|
lastRejectedAt: a.lastRejectedAt ?? null,
|
|
2156
|
+
organizationId: a.organizationId ?? null,
|
|
2157
|
+
sharesWindowWith: peers.get(a.alias) ?? [],
|
|
2158
|
+
readingFrom: a.adoptedFrom ?? null,
|
|
2111
2159
|
// Raw streak, not just the cooldown boolean: a single 401 also
|
|
2112
2160
|
// shows `auth-cooldown` for 60s, indistinguishable from a
|
|
2113
2161
|
// genuinely dead refresh token by that field alone. The magnitude
|
|
@@ -2179,6 +2227,7 @@ export async function startProxy(opts = {}) {
|
|
|
2179
2227
|
// the `dario accounts` CLI, not HTTP.
|
|
2180
2228
|
if (urlPath === '/accounts' && req.method === 'GET') {
|
|
2181
2229
|
const now = Date.now();
|
|
2230
|
+
const peers = windowPeers(pool.all(), now);
|
|
2182
2231
|
const accounts = pool.all().map(a => {
|
|
2183
2232
|
const inCooldown = isInAuthCooldown(a, now);
|
|
2184
2233
|
const cooldownMs = inCooldown && a.lastAuthFailureAt
|
|
@@ -2214,6 +2263,14 @@ export async function startProxy(opts = {}) {
|
|
|
2214
2263
|
// parked seat no longer reads as one that was never called.
|
|
2215
2264
|
rejectedCount: a.rejectedCount,
|
|
2216
2265
|
lastRejectedAt: a.lastRejectedAt ?? null,
|
|
2266
|
+
// Which organization the token belongs to, and which other seats
|
|
2267
|
+
// report the same live window — one subscription under several
|
|
2268
|
+
// aliases (dario#1244).
|
|
2269
|
+
organizationId: a.organizationId ?? null,
|
|
2270
|
+
sharesWindowWith: peers.get(a.alias) ?? [],
|
|
2271
|
+
// Whose reading this is: a peer instance's id (shared pool state)
|
|
2272
|
+
// or null for this instance's own.
|
|
2273
|
+
readingFrom: a.adoptedFrom ?? null,
|
|
2217
2274
|
expiresInMs: Math.max(0, a.expiresAt - now),
|
|
2218
2275
|
// Refresh-token grant age (refresh-grant.ts): the wall a token
|
|
2219
2276
|
// refresh cannot move. null fields = grant date unknown.
|
|
@@ -2236,6 +2293,11 @@ export async function startProxy(opts = {}) {
|
|
|
2236
2293
|
mode: 'pool',
|
|
2237
2294
|
...pool.status(),
|
|
2238
2295
|
stickyBindings: pool.stickyCount(),
|
|
2296
|
+
// Windows the pool really has: each measured window once, each
|
|
2297
|
+
// unmeasured seat as its own.
|
|
2298
|
+
distinctWindows: distinctWindows(pool.all(), now),
|
|
2299
|
+
// Shared pool state (pool-sync.ts) — null when off.
|
|
2300
|
+
sharedState: poolSync ? poolSync.status() : null,
|
|
2239
2301
|
accounts,
|
|
2240
2302
|
}));
|
|
2241
2303
|
return;
|
|
@@ -2462,12 +2524,19 @@ export async function startProxy(opts = {}) {
|
|
|
2462
2524
|
res.end(JSON.stringify(buildHaltErrorBody(state)));
|
|
2463
2525
|
return;
|
|
2464
2526
|
}
|
|
2527
|
+
// Who this request is for (dario#1244 follow-up). An `x-dario-consumer`
|
|
2528
|
+
// header names the consumer for both the per-consumer concurrency cap and
|
|
2529
|
+
// attribution. Without one, attribution falls back to a hash of the
|
|
2530
|
+
// body's user id once the body is parsed; the cap needs the name before
|
|
2531
|
+
// the slot is taken, so only the header gates.
|
|
2532
|
+
const consumerFromHeaders = consumerFromHeader(req.headers[CONSUMER_HEADER]);
|
|
2533
|
+
let consumer = consumerFromHeaders;
|
|
2465
2534
|
// Proxy to Anthropic (with concurrency control). The bounded queue
|
|
2466
2535
|
// replaces the v3.30.x-and-earlier unbounded semaphore — dario#80. A
|
|
2467
2536
|
// queue-full condition returns an explicit 429 with a `"queue-full"`
|
|
2468
2537
|
// marker in the body; a queue-timeout returns 504 with `"queue-timeout"`.
|
|
2469
2538
|
try {
|
|
2470
|
-
await queue.acquire();
|
|
2539
|
+
await queue.acquire(consumerFromHeaders);
|
|
2471
2540
|
}
|
|
2472
2541
|
catch (err) {
|
|
2473
2542
|
if (err instanceof QueueFullError) {
|
|
@@ -2757,8 +2826,11 @@ export async function startProxy(opts = {}) {
|
|
|
2757
2826
|
const v = JSON.parse(text);
|
|
2758
2827
|
if (v === null || typeof v !== 'object' || Array.isArray(v))
|
|
2759
2828
|
invalid = 'request body must be a JSON object';
|
|
2760
|
-
else
|
|
2829
|
+
else {
|
|
2761
2830
|
parsedBody = v;
|
|
2831
|
+
if (!consumer)
|
|
2832
|
+
consumer = consumerFromBody(parsedBody);
|
|
2833
|
+
}
|
|
2762
2834
|
}
|
|
2763
2835
|
catch (err) {
|
|
2764
2836
|
invalid = `request body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -3055,6 +3127,7 @@ export async function startProxy(opts = {}) {
|
|
|
3055
3127
|
codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
|
|
3056
3128
|
analytics.record({
|
|
3057
3129
|
timestamp: Date.now(),
|
|
3130
|
+
consumer,
|
|
3058
3131
|
account: o.alias,
|
|
3059
3132
|
model: o.model || rawModel || 'codex',
|
|
3060
3133
|
inputTokens: o.inputTokens, outputTokens: o.outputTokens,
|
|
@@ -3072,13 +3145,13 @@ export async function startProxy(opts = {}) {
|
|
|
3072
3145
|
method: req.method ?? '', path: urlPath, model: o.model || rawModel || undefined,
|
|
3073
3146
|
status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
|
|
3074
3147
|
cache_read: o.cacheReadTokens, cache_create: o.cacheCreateTokens,
|
|
3075
|
-
claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, stream: o.stream,
|
|
3148
|
+
claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, consumer, stream: o.stream,
|
|
3076
3149
|
});
|
|
3077
3150
|
if (verbose)
|
|
3078
3151
|
console.log(formatUsageLogLine(codexReq, {
|
|
3079
3152
|
inputTokens: o.inputTokens, outputTokens: o.outputTokens,
|
|
3080
3153
|
cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
|
|
3081
|
-
}));
|
|
3154
|
+
}, consumer));
|
|
3082
3155
|
},
|
|
3083
3156
|
// Cool codex on a rate limit only — a 5xx or an unreachable backend
|
|
3084
3157
|
// is an outage, and parking a provider for that would keep it out
|
|
@@ -3332,7 +3405,20 @@ export async function startProxy(opts = {}) {
|
|
|
3332
3405
|
// Rotating off mid-session costs cache-create on every turn.
|
|
3333
3406
|
stickyKey = computeStickyKey(userMsg);
|
|
3334
3407
|
if (stickyKey && !pinnedAccount) {
|
|
3408
|
+
// Shared state (pool-sync.ts): a conversation a peer instance
|
|
3409
|
+
// already bound lands on the same seat here, so its prompt
|
|
3410
|
+
// cache is read rather than rewritten. Only consulted when this
|
|
3411
|
+
// instance holds no binding of its own; a binding made here is
|
|
3412
|
+
// published for the peers.
|
|
3413
|
+
const hadLocalBinding = pool.stickyAliasFor(stickyKey) !== null;
|
|
3414
|
+
if (poolSync && !hadLocalBinding) {
|
|
3415
|
+
const peerAlias = await poolSync.lookupSticky(stickyKey);
|
|
3416
|
+
if (peerAlias)
|
|
3417
|
+
pool.rebindSticky(stickyKey, peerAlias);
|
|
3418
|
+
}
|
|
3335
3419
|
const preferred = pool.selectSticky(stickyKey, modelFamily(requestModel));
|
|
3420
|
+
if (poolSync && preferred && !hadLocalBinding)
|
|
3421
|
+
poolSync.bindSticky(stickyKey, preferred.alias);
|
|
3336
3422
|
if (preferred && preferred.alias !== poolAccount?.alias) {
|
|
3337
3423
|
poolAccount = preferred;
|
|
3338
3424
|
accessToken = preferred.accessToken;
|
|
@@ -3746,6 +3832,14 @@ export async function startProxy(opts = {}) {
|
|
|
3746
3832
|
else {
|
|
3747
3833
|
pool.updateRateLimits(poolAccount.alias, snapshot);
|
|
3748
3834
|
}
|
|
3835
|
+
// Which organization answered (dario#1244). Held in the pool at
|
|
3836
|
+
// once; written to the seat's record by its next token refresh —
|
|
3837
|
+
// the write that already exists — so nothing here races a refresh.
|
|
3838
|
+
const organizationId = upstream.headers.get('anthropic-organization-id');
|
|
3839
|
+
if (organizationId)
|
|
3840
|
+
pool.noteOrganization(poolAccount.alias, organizationId);
|
|
3841
|
+
announceWindowPeers(poolAccount.alias);
|
|
3842
|
+
poolSync?.reportSeat(poolAccount.alias);
|
|
3749
3843
|
// First-sight detector for per-model rate-limit buckets. Anthropic
|
|
3750
3844
|
// ships these unannounced — e.g. `7d_sonnet-utilization` appeared
|
|
3751
3845
|
// around 2026-04-25 — and verbose-mode users want a heads-up the
|
|
@@ -4021,6 +4115,7 @@ export async function startProxy(opts = {}) {
|
|
|
4021
4115
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
4022
4116
|
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
4023
4117
|
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
4118
|
+
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
4024
4119
|
peekedBody = null;
|
|
4025
4120
|
continue dispatchLoop;
|
|
4026
4121
|
}
|
|
@@ -4068,6 +4163,7 @@ export async function startProxy(opts = {}) {
|
|
|
4068
4163
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
4069
4164
|
analytics.record({
|
|
4070
4165
|
timestamp: Date.now(),
|
|
4166
|
+
consumer,
|
|
4071
4167
|
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
4072
4168
|
model: requestModel,
|
|
4073
4169
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
@@ -4121,6 +4217,7 @@ export async function startProxy(opts = {}) {
|
|
|
4121
4217
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
4122
4218
|
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
4123
4219
|
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
4220
|
+
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
4124
4221
|
continue dispatchLoop;
|
|
4125
4222
|
}
|
|
4126
4223
|
// No peer available — forward the saved generic-403 bytes when the
|
|
@@ -4154,6 +4251,7 @@ export async function startProxy(opts = {}) {
|
|
|
4154
4251
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
4155
4252
|
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
4156
4253
|
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
4254
|
+
poolSync?.bindSticky(stickyKey, nextAccount.alias);
|
|
4157
4255
|
continue dispatchLoop;
|
|
4158
4256
|
}
|
|
4159
4257
|
}
|
|
@@ -4189,6 +4287,7 @@ export async function startProxy(opts = {}) {
|
|
|
4189
4287
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
4190
4288
|
analytics.record({
|
|
4191
4289
|
timestamp: Date.now(),
|
|
4290
|
+
consumer,
|
|
4192
4291
|
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
4193
4292
|
model: requestModel,
|
|
4194
4293
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
@@ -4446,6 +4545,7 @@ export async function startProxy(opts = {}) {
|
|
|
4446
4545
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
4447
4546
|
analytics.record({
|
|
4448
4547
|
timestamp: Date.now(),
|
|
4548
|
+
consumer,
|
|
4449
4549
|
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
4450
4550
|
model: requestModel,
|
|
4451
4551
|
inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
|
|
@@ -4465,6 +4565,7 @@ export async function startProxy(opts = {}) {
|
|
|
4465
4565
|
claim: poolAccount?.rateLimit.claim,
|
|
4466
4566
|
bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
|
|
4467
4567
|
account: poolAccount?.alias,
|
|
4568
|
+
consumer,
|
|
4468
4569
|
client: detectedClientForLog,
|
|
4469
4570
|
preserve_tools: preserveToolsEffective,
|
|
4470
4571
|
stream: true,
|
|
@@ -4473,7 +4574,7 @@ export async function startProxy(opts = {}) {
|
|
|
4473
4574
|
console.log(formatUsageLogLine(requestCount, {
|
|
4474
4575
|
inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
|
|
4475
4576
|
cacheReadTokens: streamCacheReadTokens, cacheCreateTokens: streamCacheCreateTokens,
|
|
4476
|
-
}));
|
|
4577
|
+
}, consumer));
|
|
4477
4578
|
}
|
|
4478
4579
|
else {
|
|
4479
4580
|
// Buffer and forward
|
|
@@ -4512,6 +4613,7 @@ export async function startProxy(opts = {}) {
|
|
|
4512
4613
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
4513
4614
|
analytics.record({
|
|
4514
4615
|
timestamp: Date.now(),
|
|
4616
|
+
consumer,
|
|
4515
4617
|
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
4516
4618
|
model: bufferedUsage.model || requestModel,
|
|
4517
4619
|
inputTokens: bufferedUsage.inputTokens, outputTokens: bufferedUsage.outputTokens,
|
|
@@ -4533,12 +4635,13 @@ export async function startProxy(opts = {}) {
|
|
|
4533
4635
|
claim: poolAccount?.rateLimit.claim,
|
|
4534
4636
|
bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
|
|
4535
4637
|
account: poolAccount?.alias,
|
|
4638
|
+
consumer,
|
|
4536
4639
|
client: detectedClientForLog,
|
|
4537
4640
|
preserve_tools: preserveToolsEffective,
|
|
4538
4641
|
stream: false,
|
|
4539
4642
|
});
|
|
4540
4643
|
if (verbose && bufferedUsage)
|
|
4541
|
-
console.log(formatUsageLogLine(requestCount, bufferedUsage));
|
|
4644
|
+
console.log(formatUsageLogLine(requestCount, bufferedUsage, consumer));
|
|
4542
4645
|
if (verbose)
|
|
4543
4646
|
console.log(`[dario] #${requestCount} ${upstream.status}`);
|
|
4544
4647
|
}
|
|
@@ -4550,6 +4653,7 @@ export async function startProxy(opts = {}) {
|
|
|
4550
4653
|
ts: new Date().toISOString(), req: requestCount,
|
|
4551
4654
|
method: req.method ?? '', path: urlPath,
|
|
4552
4655
|
model: requestModel || undefined,
|
|
4656
|
+
consumer,
|
|
4553
4657
|
client: detectedClientForLog,
|
|
4554
4658
|
preserve_tools: preserveToolsEffective,
|
|
4555
4659
|
};
|
|
@@ -4590,7 +4694,7 @@ export async function startProxy(opts = {}) {
|
|
|
4590
4694
|
clearTimeout(upstreamTimeout);
|
|
4591
4695
|
if (onClientClose !== null)
|
|
4592
4696
|
req.off('close', onClientClose);
|
|
4593
|
-
queue.release();
|
|
4697
|
+
queue.release(consumerFromHeaders);
|
|
4594
4698
|
}
|
|
4595
4699
|
});
|
|
4596
4700
|
server.on('error', async (err) => {
|
|
@@ -4819,6 +4923,7 @@ export async function startProxy(opts = {}) {
|
|
|
4819
4923
|
console.log('\n[dario] Shutting down...');
|
|
4820
4924
|
clearInterval(presenceInterval);
|
|
4821
4925
|
clearInterval(refreshInterval);
|
|
4926
|
+
poolSync?.stop();
|
|
4822
4927
|
if (logFileStream)
|
|
4823
4928
|
logFileStream.end();
|
|
4824
4929
|
// Flush tokens first (best-effort, bounded), then close the server. The
|
package/dist/request-queue.d.ts
CHANGED
|
@@ -52,6 +52,10 @@ export interface QueueState {
|
|
|
52
52
|
*/
|
|
53
53
|
export interface QueueSnapshot extends QueueState {
|
|
54
54
|
stalledSince: number | null;
|
|
55
|
+
/** Per-consumer in-flight ceiling (`--max-concurrent-per-consumer`); 0 = off. */
|
|
56
|
+
maxConcurrentPerConsumer: number;
|
|
57
|
+
/** Distinct consumers with a request in flight right now. */
|
|
58
|
+
consumersActive: number;
|
|
55
59
|
}
|
|
56
60
|
export type AdmitDecision = {
|
|
57
61
|
action: 'admit';
|
|
@@ -63,6 +67,14 @@ export type AdmitDecision = {
|
|
|
63
67
|
};
|
|
64
68
|
/** Pure admission decision — no side effects, no clock dep. */
|
|
65
69
|
export declare function decideAdmit(state: QueueState): AdmitDecision;
|
|
70
|
+
/**
|
|
71
|
+
* Pure per-consumer gate (dario#1244 follow-up — a team gateway where one
|
|
72
|
+
* heavy user could hold every slot). A consumer already holding `cap` slots
|
|
73
|
+
* waits even when the queue has room: `enqueue` if it does, `reject` if
|
|
74
|
+
* not. Returns null when the gate does not apply (cap off, or the consumer
|
|
75
|
+
* is under it), so `decideAdmit` decides as before.
|
|
76
|
+
*/
|
|
77
|
+
export declare function decideConsumerAdmit(activeForConsumer: number, cap: number, state: QueueState): AdmitDecision | null;
|
|
66
78
|
/** Pure timeout check — separated so tests can pass an explicit clock. */
|
|
67
79
|
export declare function isQueueEntryExpired(enqueuedAt: number, now: number, timeoutMs: number): boolean;
|
|
68
80
|
export declare class QueueFullError extends Error {
|
|
@@ -75,6 +87,13 @@ export interface RequestQueueOptions {
|
|
|
75
87
|
maxConcurrent?: number;
|
|
76
88
|
maxQueued?: number;
|
|
77
89
|
queueTimeoutMs?: number;
|
|
90
|
+
/**
|
|
91
|
+
* In-flight ceiling per consumer (see `acquire(consumer)`). 0 / unset =
|
|
92
|
+
* off. A consumer at its cap waits in the queue; its waiters never block
|
|
93
|
+
* another consumer's — `release` admits the first waiter whose consumer
|
|
94
|
+
* is under the cap, not the first waiter.
|
|
95
|
+
*/
|
|
96
|
+
maxConcurrentPerConsumer?: number;
|
|
78
97
|
/**
|
|
79
98
|
* Whether timeout timers are `unref`'d so they don't by themselves keep
|
|
80
99
|
* the Node event loop alive. Default `true` — appropriate for the proxy,
|
|
@@ -95,8 +114,10 @@ export declare class RequestQueue {
|
|
|
95
114
|
readonly maxConcurrent: number;
|
|
96
115
|
readonly maxQueued: number;
|
|
97
116
|
readonly queueTimeoutMs: number;
|
|
117
|
+
readonly maxConcurrentPerConsumer: number;
|
|
98
118
|
readonly unrefTimers: boolean;
|
|
99
119
|
private active;
|
|
120
|
+
private activeByConsumer;
|
|
100
121
|
private queue;
|
|
101
122
|
private readonly now;
|
|
102
123
|
private stalledSince;
|
|
@@ -111,15 +132,24 @@ export declare class RequestQueue {
|
|
|
111
132
|
* `release()` refreshes it, by clearing first (see there).
|
|
112
133
|
*/
|
|
113
134
|
private updateStall;
|
|
135
|
+
/** A consumer is under its cap when there is no cap, no consumer, or room. */
|
|
136
|
+
private underCap;
|
|
137
|
+
private admit;
|
|
114
138
|
/**
|
|
115
139
|
* Acquire a concurrency slot. Resolves when admitted; throws
|
|
116
140
|
* `QueueFullError` when the queue is at its `maxQueued` cap, throws
|
|
117
141
|
* `QueueTimeoutError` when a queued request waited longer than
|
|
118
|
-
* `queueTimeoutMs`.
|
|
142
|
+
* `queueTimeoutMs`. `consumer` names who the request is for: with a
|
|
143
|
+
* per-consumer cap set, a consumer at its cap waits even while slots are
|
|
144
|
+
* free, and `release(consumer)` must be called with the same name.
|
|
145
|
+
*/
|
|
146
|
+
acquire(consumer?: string): Promise<void>;
|
|
147
|
+
/**
|
|
148
|
+
* Release a slot. The first queued entry whose consumer is under its cap is
|
|
149
|
+
* admitted — FIFO among the admissible, so a capped consumer's waiters do
|
|
150
|
+
* not hold up anyone else's; they get in when that consumer releases.
|
|
119
151
|
*/
|
|
120
|
-
|
|
121
|
-
/** Release a slot. The next queued entry (if any) is admitted in FIFO order. */
|
|
122
|
-
release(): void;
|
|
152
|
+
release(consumer?: string): void;
|
|
123
153
|
/** Snapshot of queue state — exposed for /health + /analytics + tests. */
|
|
124
154
|
snapshot(): QueueSnapshot;
|
|
125
155
|
}
|